Basic impl added
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
testdata/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Kubernetes
|
||||
k8s/
|
||||
*.yaml
|
||||
!config.yaml
|
||||
|
||||
# Development
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/notifier.iml" filepath="$PROJECT_DIR$/.idea/notifier.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
# Notifier Microservice Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The Notifier microservice is designed to provide a flexible, scalable notification delivery system that supports multiple notification channels (SMTP, Slack, Ntfy, Stdout) with both REST and gRPC APIs.
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
- **Clean Architecture**: Domain-driven design with clear separation of concerns
|
||||
- **Interface-based Design**: All core components use interfaces for maximum flexibility
|
||||
- **Pluggable Notifiers**: Easy to add new notification providers
|
||||
- **Queue Abstraction**: Supports both local and distributed queues (Kafka)
|
||||
- **Configuration-driven**: Viper-based configuration with environment variable support
|
||||
- **Cloud-native**: Containerized with Kubernetes support
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Domain Layer (`internal/domain/`)
|
||||
|
||||
The domain layer defines the core business logic and interfaces:
|
||||
|
||||
#### `notification.go`
|
||||
- **Notification**: Core notification entity with metadata, status tracking, and retry logic
|
||||
- **Priority**: Enumeration for notification urgency (Low, Normal, High, Critical)
|
||||
- **NotificationType**: Supported notification channels (Email, Slack, Ntfy, Stdout)
|
||||
- **NotificationStatus**: Lifecycle states (Pending, Queued, Processing, Sent, Failed, Retrying)
|
||||
- **NotificationResult**: Outcome of notification delivery attempts
|
||||
- **NotificationFilter**: Query interface for retrieving notifications
|
||||
|
||||
#### `notifier.go`
|
||||
- **Notifier**: Core interface that all notification implementations must satisfy
|
||||
- **NotifierFactory**: Factory pattern for creating notifier instances
|
||||
- **NotificationService**: High-level service interface for notification operations
|
||||
- **NotificationStats**: Statistics and metrics about notification processing
|
||||
|
||||
#### `queue.go`
|
||||
- **Queue**: Interface for notification queue implementations
|
||||
- **QueueMessage**: Wrapper around notifications with queue-specific metadata
|
||||
- **QueueConfig**: Configuration for queue implementations
|
||||
- **LocalQueueConfig**: In-memory queue configuration
|
||||
- **KafkaQueueConfig**: Distributed Kafka queue configuration
|
||||
|
||||
### 2. Queue Implementation (`internal/queue/`)
|
||||
|
||||
#### `local.go`
|
||||
- In-memory queue implementation using Go channels
|
||||
- Optional disk persistence for durability
|
||||
- Thread-safe with mutex protection
|
||||
- Supports enqueue, dequeue, ack, and nack operations
|
||||
- Configurable buffer size and retry behavior
|
||||
|
||||
### 3. Notifier Implementations (`internal/notifier/`)
|
||||
|
||||
#### `notifier.go`
|
||||
- **Factory**: Manages and creates notifier instances
|
||||
- **BaseNotifier**: Common functionality shared by all notifiers
|
||||
- Validation and context checking utilities
|
||||
|
||||
#### `stdout.go`
|
||||
- Simple stdout notifier for debugging and development
|
||||
- Prints notifications to console with formatted output
|
||||
|
||||
#### `smtp.go`
|
||||
- Email notifications via SMTP
|
||||
- Supports TLS/SSL
|
||||
- Configurable SMTP server, port, and authentication
|
||||
- RFC-compliant email message formatting
|
||||
|
||||
#### `ntfy.go`
|
||||
- Integration with ntfy.sh push notification service
|
||||
- Supports custom ntfy servers
|
||||
- Priority mapping and metadata support
|
||||
- Rich notification features (tags, click actions, attachments)
|
||||
|
||||
#### `slack.go`
|
||||
- Slack webhook integration
|
||||
- Channel-specific webhook support
|
||||
- Rich message formatting with blocks
|
||||
- Priority indicators and custom branding
|
||||
|
||||
### 4. Configuration (`internal/config/`)
|
||||
|
||||
#### `config.go`
|
||||
- Viper-based configuration management
|
||||
- Support for YAML config files and environment variables
|
||||
- Default value handling
|
||||
- Comprehensive validation
|
||||
- Hierarchical configuration structure:
|
||||
- Server settings (ports, host, mode)
|
||||
- Queue configuration
|
||||
- Notifier credentials
|
||||
- Logging settings
|
||||
- Metrics and observability
|
||||
- Health check configuration
|
||||
|
||||
### 5. API Layer
|
||||
|
||||
#### gRPC API (`api/grpc/`)
|
||||
|
||||
**`notifier.proto`**
|
||||
- Protocol buffer definitions for the gRPC service
|
||||
- Operations:
|
||||
- `SendNotification`: Send single notification
|
||||
- `SendBatchNotifications`: Send multiple notifications
|
||||
- `GetNotification`: Retrieve notification by ID
|
||||
- `ListNotifications`: Query notifications with filters
|
||||
- `CancelNotification`: Cancel pending notification
|
||||
- `RetryNotification`: Retry failed notification
|
||||
- `GetStats`: Retrieve service statistics
|
||||
- `HealthCheck`: Service health verification
|
||||
|
||||
#### REST API (`api/rest/`)
|
||||
|
||||
**`handlers.go`**
|
||||
- HTTP handlers for REST endpoints
|
||||
- Request validation and error handling
|
||||
- JSON serialization/deserialization
|
||||
|
||||
**`router.go`**
|
||||
- Gorilla mux router configuration
|
||||
- Middleware for logging and CORS
|
||||
- Route definitions matching gRPC operations
|
||||
|
||||
**`types.go`**
|
||||
- REST API request/response types
|
||||
- Domain model conversions
|
||||
- Validation logic
|
||||
|
||||
### 6. Entry Points (`cmd/`)
|
||||
|
||||
#### `grpcserver/main.go`
|
||||
- Standalone gRPC server
|
||||
- Service initialization and dependency injection
|
||||
- Graceful shutdown handling
|
||||
|
||||
#### `restserver/main.go`
|
||||
- Standalone REST server
|
||||
- HTTP server configuration
|
||||
- Graceful shutdown handling
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Sending a Notification
|
||||
|
||||
```
|
||||
Client Request (REST/gRPC)
|
||||
↓
|
||||
API Handler
|
||||
↓
|
||||
NotificationService.Send()
|
||||
↓
|
||||
Queue.Enqueue()
|
||||
↓
|
||||
[Notification queued]
|
||||
↓
|
||||
Worker dequeues (Queue.Dequeue())
|
||||
↓
|
||||
NotifierFactory.Create()
|
||||
↓
|
||||
Notifier.Send()
|
||||
↓
|
||||
Provider API (SMTP/Slack/Ntfy/Stdout)
|
||||
↓
|
||||
Queue.Ack() or Queue.Nack()
|
||||
↓
|
||||
Update notification status
|
||||
↓
|
||||
[Notification sent or failed]
|
||||
```
|
||||
|
||||
## Queue Strategies
|
||||
|
||||
### Local Queue
|
||||
- In-memory implementation using Go channels
|
||||
- Fast and simple for single-instance deployments
|
||||
- Optional disk persistence for durability
|
||||
- Suitable for development and small-scale production
|
||||
|
||||
### Kafka Queue (Future)
|
||||
- Distributed queue for multi-instance deployments
|
||||
- Guarantees delivery across service restarts
|
||||
- Horizontal scalability
|
||||
- Exactly-once semantics with idempotence
|
||||
- Suitable for high-throughput production environments
|
||||
|
||||
## Notification Lifecycle
|
||||
|
||||
1. **Pending**: Notification created but not yet queued
|
||||
2. **Queued**: Added to queue, waiting for processing
|
||||
3. **Processing**: Worker has dequeued and is sending
|
||||
4. **Sent**: Successfully delivered to provider
|
||||
5. **Failed**: Delivery failed after max retries
|
||||
6. **Retrying**: Temporarily failed, will retry
|
||||
|
||||
## Retry Strategy
|
||||
|
||||
- Configurable max retries (default: 3)
|
||||
- Pluggable backoff strategies:
|
||||
- **Exponential**: 2^n delay between retries
|
||||
- **Linear**: Fixed increment between retries
|
||||
- **Fixed**: Constant delay between retries
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Hierarchy (highest to lowest priority)
|
||||
1. Environment variables (prefixed with `NOTIFIER_`)
|
||||
2. Configuration file (config.yaml)
|
||||
3. Default values
|
||||
|
||||
### Example Environment Variables
|
||||
```bash
|
||||
NOTIFIER_SERVER_GRPC_PORT=50051
|
||||
NOTIFIER_SERVER_REST_PORT=8080
|
||||
NOTIFIER_QUEUE_TYPE=local
|
||||
NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com
|
||||
NOTIFIER_NOTIFIERS_SLACK_WEBHOOK_URL=https://hooks.slack.com/...
|
||||
```
|
||||
|
||||
## Deployment Strategies
|
||||
|
||||
### Docker Compose
|
||||
- Single-host deployment
|
||||
- All components in one compose file
|
||||
- Optional Kafka, Prometheus, and Grafana services
|
||||
- Volume mounts for configuration and persistence
|
||||
|
||||
### Kubernetes
|
||||
- Multi-instance deployment with HPA
|
||||
- Separate services for REST and gRPC
|
||||
- ConfigMaps for configuration
|
||||
- Secrets for credentials
|
||||
- Ingress for external access
|
||||
- Health checks and readiness probes
|
||||
- Resource limits and requests
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
#### Horizontal Scaling
|
||||
- Multiple instances can run concurrently
|
||||
- Use Kafka queue for distributed message processing
|
||||
- Stateless design allows easy scaling
|
||||
|
||||
#### Vertical Scaling
|
||||
- Increase worker count per instance
|
||||
- Tune queue buffer sizes
|
||||
- Adjust resource limits
|
||||
|
||||
## Observability
|
||||
|
||||
### Metrics (Prometheus)
|
||||
- Total notifications sent/failed
|
||||
- Notifications by type and status
|
||||
- Average latency
|
||||
- Queue size
|
||||
- Worker utilization
|
||||
|
||||
### Health Checks
|
||||
- Dedicated health endpoint
|
||||
- Component-level health reporting
|
||||
- Kubernetes liveness/readiness probes
|
||||
|
||||
### Logging
|
||||
- Structured JSON logging
|
||||
- Configurable log levels
|
||||
- Request/response logging
|
||||
- Error tracking
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication
|
||||
- API keys for REST endpoints (to be implemented)
|
||||
- mTLS for gRPC (to be implemented)
|
||||
- Kubernetes RBAC for service account
|
||||
|
||||
### Authorization
|
||||
- Per-notifier credential management
|
||||
- Secrets stored in Kubernetes Secrets or external secret manager
|
||||
- No credentials in configuration files
|
||||
|
||||
### Network Security
|
||||
- Non-root container user
|
||||
- Read-only root filesystem where possible
|
||||
- Minimal container image (Alpine-based)
|
||||
- Security context restrictions
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding a New Notifier
|
||||
|
||||
1. Create new file in `internal/notifier/`
|
||||
2. Implement the `domain.Notifier` interface:
|
||||
- `Send(ctx, notification) -> result`
|
||||
- `Type() -> NotificationType`
|
||||
- `Validate(notification) -> error`
|
||||
- `Close() -> error`
|
||||
3. Add configuration struct to `internal/config/`
|
||||
4. Register in factory during initialization
|
||||
5. Update protobuf and REST API types
|
||||
6. Add configuration example to `config.yaml`
|
||||
|
||||
### Adding a New Queue Implementation
|
||||
|
||||
1. Create new file in `internal/queue/`
|
||||
2. Implement the `domain.Queue` interface
|
||||
3. Add configuration to `domain.QueueConfig`
|
||||
4. Update queue factory/initialization logic
|
||||
5. Add configuration example
|
||||
|
||||
## Next Steps for Implementation
|
||||
|
||||
1. **Generate Protocol Buffers**: Run `make proto-gen` to generate gRPC code
|
||||
2. **Implement Service Layer**: Create the main service that ties everything together
|
||||
3. **Implement Server Main Functions**: Wire up dependencies in `cmd/`
|
||||
4. **Add Tests**: Unit tests for notifiers, integration tests for queue
|
||||
5. **Add Kafka Queue**: Implement distributed queue using Kafka
|
||||
6. **Add Metrics**: Prometheus instrumentation
|
||||
7. **Add Authentication**: API key or OAuth support
|
||||
8. **Add Rate Limiting**: Prevent abuse and manage provider quotas
|
||||
9. **Add Notification Templates**: Support for templated messages
|
||||
10. **Add Webhooks**: Allow callbacks on notification status changes
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Build stage
|
||||
FROM golang:1.23.2-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build binaries
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o grpcserver ./cmd/grpcserver
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o restserver ./cmd/restserver
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 notifier && \
|
||||
adduser -D -u 1000 -G notifier notifier
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binaries from builder
|
||||
COPY --from=builder /build/grpcserver /app/
|
||||
COPY --from=builder /build/restserver /app/
|
||||
|
||||
# Copy default config (can be overridden with volume mount)
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
# Create directory for queue persistence
|
||||
RUN mkdir -p /var/lib/notifier && \
|
||||
chown -R notifier:notifier /var/lib/notifier
|
||||
|
||||
# Change to non-root user
|
||||
USER notifier
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8080 50051 9090 8081
|
||||
|
||||
# Default to running both servers
|
||||
# Can be overridden with docker run command
|
||||
CMD ["sh", "-c", "/app/grpcserver & /app/restserver"]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
.PHONY: proto proto-gen proto-clean deps build run-grpc run-rest run-both test lint docker-build docker-run clean
|
||||
|
||||
# Variables
|
||||
PROTO_DIR=api/grpc
|
||||
PROTO_FILE=$(PROTO_DIR)/notifier.proto
|
||||
PROTO_OUT=$(PROTO_DIR)/pb
|
||||
GO_MODULE=$(shell head -n 1 go.mod | awk '{print $$2}')
|
||||
|
||||
# Generate protobuf code
|
||||
proto-gen:
|
||||
@echo "Generating protobuf code..."
|
||||
@mkdir -p $(PROTO_OUT)
|
||||
protoc --go_out=$(PROTO_OUT) --go_opt=paths=source_relative \
|
||||
--go-grpc_out=$(PROTO_OUT) --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_FILE)
|
||||
@echo "Protobuf code generated successfully"
|
||||
|
||||
# Clean generated protobuf code
|
||||
proto-clean:
|
||||
@echo "Cleaning generated protobuf code..."
|
||||
@rm -rf $(PROTO_OUT)
|
||||
@echo "Cleaned"
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
@echo "Installing dependencies..."
|
||||
go mod download
|
||||
go mod tidy
|
||||
@echo "Dependencies installed"
|
||||
|
||||
# Install protoc plugins
|
||||
proto-deps:
|
||||
@echo "Installing protoc plugins..."
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
@echo "Protoc plugins installed"
|
||||
|
||||
# Build binaries
|
||||
build:
|
||||
@echo "Building binaries..."
|
||||
@mkdir -p bin
|
||||
go build -o bin/grpcserver ./cmd/grpcserver
|
||||
go build -o bin/restserver ./cmd/restserver
|
||||
@echo "Binaries built successfully"
|
||||
|
||||
# Run gRPC server
|
||||
run-grpc:
|
||||
@echo "Running gRPC server..."
|
||||
go run ./cmd/grpcserver/main.go
|
||||
|
||||
# Run REST server
|
||||
run-rest:
|
||||
@echo "Running REST server..."
|
||||
go run ./cmd/restserver/main.go
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
go test -v -race -cover ./...
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
golangci-lint run ./...
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t notifier:latest .
|
||||
@echo "Docker image built successfully"
|
||||
|
||||
# Run Docker container
|
||||
docker-run:
|
||||
@echo "Running Docker container..."
|
||||
docker run -p 8080:8080 -p 50051:50051 -v $(PWD)/config.yaml:/app/config.yaml notifier:latest
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf bin/
|
||||
@rm -rf $(PROTO_OUT)
|
||||
@echo "Cleaned"
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " proto-gen - Generate protobuf code"
|
||||
@echo " proto-clean - Clean generated protobuf code"
|
||||
@echo " proto-deps - Install protoc plugins"
|
||||
@echo " deps - Install Go dependencies"
|
||||
@echo " build - Build binaries"
|
||||
@echo " run-grpc - Run gRPC server"
|
||||
@echo " run-rest - Run REST server"
|
||||
@echo " test - Run tests"
|
||||
@echo " lint - Run linter"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-run - Run Docker container"
|
||||
@echo " clean - Clean build artifacts"
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
# Quick Start Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Go 1.23.2 or higher installed
|
||||
2. Dependencies installed: `go mod tidy`
|
||||
|
||||
## Running the REST Server
|
||||
|
||||
### Option 1: Using go run
|
||||
```bash
|
||||
go run ./cmd/restserver/main.go
|
||||
```
|
||||
|
||||
### Option 2: Build and run
|
||||
```bash
|
||||
go build -o bin/restserver ./cmd/restserver
|
||||
./bin/restserver
|
||||
```
|
||||
|
||||
### Option 3: Using Make
|
||||
```bash
|
||||
make build
|
||||
make run-rest
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:8080` by default.
|
||||
|
||||
## Testing with cURL
|
||||
|
||||
### 1. Health Check
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"service": "notifier",
|
||||
"time": "2025-10-16T21:05:27Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Send a Simple Notification
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "stdout",
|
||||
"subject": "Hello World",
|
||||
"body": "This is a test notification!",
|
||||
"recipients": ["console"]
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"notification_id": "abc123...",
|
||||
"success": true,
|
||||
"message": "notification queued successfully",
|
||||
"sent_at": "2025-10-16T21:05:27Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The notification will be printed to the server's stdout.
|
||||
|
||||
### 3. Send with Priority
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "stdout",
|
||||
"priority": 4,
|
||||
"subject": "CRITICAL Alert",
|
||||
"body": "This is a critical notification!",
|
||||
"recipients": ["console"],
|
||||
"max_retries": 5
|
||||
}'
|
||||
```
|
||||
|
||||
Priority levels:
|
||||
- `0` - Low
|
||||
- `1` - Normal (default)
|
||||
- `2` - High
|
||||
- `3` - Critical
|
||||
|
||||
### 4. Send Batch Notifications
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications/batch \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"notifications": [
|
||||
{
|
||||
"type": "stdout",
|
||||
"subject": "Notification 1",
|
||||
"body": "First notification",
|
||||
"recipients": ["console"]
|
||||
},
|
||||
{
|
||||
"type": "stdout",
|
||||
"subject": "Notification 2",
|
||||
"body": "Second notification",
|
||||
"recipients": ["console"]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 5. Get Notification Statistics
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/stats
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"total_sent": 5,
|
||||
"total_failed": 0,
|
||||
"total_pending": 0,
|
||||
"total_queued": 0,
|
||||
"by_type": {
|
||||
"stdout": 5
|
||||
},
|
||||
"by_status": {
|
||||
"sent": 5
|
||||
},
|
||||
"average_latency_ms": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 6. List Notifications
|
||||
```bash
|
||||
# List all notifications
|
||||
curl http://localhost:8080/api/v1/notifications
|
||||
|
||||
# List with filters
|
||||
curl "http://localhost:8080/api/v1/notifications?type=stdout&status=sent&limit=10"
|
||||
```
|
||||
|
||||
### 7. Get Specific Notification
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/notifications/{notification-id}
|
||||
```
|
||||
|
||||
## Testing with Other Notifiers
|
||||
|
||||
### SMTP (Email)
|
||||
Update `config.yaml`:
|
||||
```yaml
|
||||
notifiers:
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
username: "your-email@gmail.com"
|
||||
password: "your-app-password"
|
||||
from: "notifications@yourservice.com"
|
||||
use_tls: true
|
||||
```
|
||||
|
||||
Then send:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "email",
|
||||
"subject": "Test Email",
|
||||
"body": "This is a test email!",
|
||||
"recipients": ["recipient@example.com"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Slack
|
||||
Update `config.yaml`:
|
||||
```yaml
|
||||
notifiers:
|
||||
slack:
|
||||
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
|
||||
username: "Notifier Bot"
|
||||
icon_emoji: ":bell:"
|
||||
```
|
||||
|
||||
Then send:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "slack",
|
||||
"subject": "Deployment Alert",
|
||||
"body": "Application deployed successfully to production!",
|
||||
"recipients": ["#alerts"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Ntfy
|
||||
Update `config.yaml`:
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
```
|
||||
|
||||
Then send:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Mobile Alert",
|
||||
"body": "This will appear on your phone!",
|
||||
"recipients": ["mytopic"],
|
||||
"metadata": {
|
||||
"tags": ["warning", "skull"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Using Environment Variables
|
||||
```bash
|
||||
export NOTIFIER_SERVER_REST_PORT=9000
|
||||
export NOTIFIER_QUEUE_WORKER_COUNT=20
|
||||
export NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com
|
||||
export NOTIFIER_NOTIFIERS_SMTP_PASSWORD=secret
|
||||
|
||||
./bin/restserver
|
||||
```
|
||||
|
||||
### Using config.yaml
|
||||
Create or modify `config.yaml` in the project root:
|
||||
```yaml
|
||||
server:
|
||||
rest_port: 8080
|
||||
host: "0.0.0.0"
|
||||
|
||||
queue:
|
||||
type: "local"
|
||||
worker_count: 10
|
||||
|
||||
notifiers:
|
||||
stdout: true
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Build Docker Image
|
||||
```bash
|
||||
docker build -t notifier:latest .
|
||||
```
|
||||
|
||||
### Run with Docker
|
||||
```bash
|
||||
docker run -p 8080:8080 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml \
|
||||
notifier:latest
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
### Deploy to Kubernetes
|
||||
```bash
|
||||
# Apply all resources
|
||||
kubectl apply -f k8s/
|
||||
|
||||
# Check status
|
||||
kubectl get pods -l app=notifier
|
||||
kubectl get svc -l app=notifier
|
||||
|
||||
# View logs
|
||||
kubectl logs -f -l app=notifier
|
||||
|
||||
# Port forward to access locally
|
||||
kubectl port-forward svc/notifier-rest 8080:8080
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Add Authentication**: Implement API key or OAuth authentication
|
||||
2. **Add Email Templates**: Support for templated notifications
|
||||
3. **Add Webhooks**: Get callbacks when notifications are sent/failed
|
||||
4. **Add Persistence**: Store notifications in a database
|
||||
5. **Add Kafka Queue**: For distributed processing
|
||||
6. **Add Metrics**: Instrument with Prometheus metrics
|
||||
7. **Add Tests**: Write unit and integration tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
- Check if port 8080 is already in use: `lsof -i :8080`
|
||||
- Check config.yaml syntax
|
||||
- Verify all dependencies are installed: `go mod tidy`
|
||||
|
||||
### Notifications not sending
|
||||
- Check server logs for errors
|
||||
- Verify the notifier is enabled in config.yaml
|
||||
- For SMTP: Verify credentials and allow less secure apps
|
||||
- For Slack: Verify webhook URL is correct
|
||||
- For Ntfy: Ensure topic name is valid
|
||||
|
||||
### Queue filling up
|
||||
- Increase worker count in config.yaml
|
||||
- Check if notifiers are failing
|
||||
- Review retry configuration
|
||||
|
||||
## API Reference
|
||||
|
||||
Full API documentation available in `ARCHITECTURE.md`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | /health | Health check |
|
||||
| POST | /api/v1/notifications | Send notification |
|
||||
| POST | /api/v1/notifications/batch | Send batch |
|
||||
| GET | /api/v1/notifications | List notifications |
|
||||
| GET | /api/v1/notifications/{id} | Get notification |
|
||||
| DELETE | /api/v1/notifications/{id} | Cancel notification |
|
||||
| POST | /api/v1/notifications/{id}/retry | Retry notification |
|
||||
| GET | /api/v1/stats | Get statistics |
|
||||
@@ -1,153 +1,623 @@
|
||||
# Notifier Microservice
|
||||
|
||||
# Notifier Module
|
||||
A production-ready notification delivery microservice that supports multiple notification channels (Email, Slack, Ntfy, Stdout) with both REST and gRPC APIs running simultaneously.
|
||||
|
||||
The **Notifier** module provides a flexible notification system that supports multiple notification modes (e.g., SMTP, SMS, Slack, Ntfy, and Stdout). It can operate as a standalone gRPC or REST microservice, or be used as an imported module in other projects.
|
||||
[](https://golang.org)
|
||||
[](LICENSE)
|
||||
|
||||
## Overview
|
||||
|
||||
Notifier is a cloud-native microservice designed to handle all your application's notification needs through a single, unified API. Send emails, Slack messages, push notifications, and more with a consistent interface and robust delivery guarantees.
|
||||
|
||||
Perfect for:
|
||||
- Microservices architectures needing centralized notifications
|
||||
- Applications requiring multiple notification channels
|
||||
- Systems needing reliable async notification delivery
|
||||
- Teams wanting to standardize notification handling
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Mode Notification**: Supports different notification methods including SMTP email, SMS, Slack, Ntfy, and standard output.
|
||||
- **Extensible Design**: Add new notification modes by implementing the `Notifier` interface.
|
||||
- **gRPC and REST APIs**: Accessible through both gRPC and RESTful APIs, allowing easy integration into various systems.
|
||||
- **Configuration Management**: Easily configurable for different environments and API keys.
|
||||
### Core Capabilities
|
||||
- 🔔 **Multi-Channel Support**: Email (SMTP), Slack, Ntfy.sh, and Stdout
|
||||
- 🚀 **Dual API**: REST and gRPC running simultaneously in one process
|
||||
- 📦 **Queue-Based**: Async processing with configurable worker pools
|
||||
- 🔄 **Retry Logic**: Exponential backoff with configurable attempts
|
||||
- ⚡ **Priority Levels**: Low, Normal, High, and Critical
|
||||
- 📊 **Batch Operations**: Send multiple notifications efficiently
|
||||
- 🎯 **Status Tracking**: Monitor notification lifecycle and delivery
|
||||
|
||||
### Production Features
|
||||
- ⚙️ **Configuration**: Viper-based with environment variable support
|
||||
- 🐳 **Containerized**: Multi-stage Docker builds with non-root user
|
||||
- ☸️ **Kubernetes Ready**: Complete manifests with HPA, health checks, and RBAC
|
||||
- 🔒 **Secure**: Token-based auth for ntfy, TLS support, secret management
|
||||
- 📈 **Observable**: Health endpoints, metrics support, structured logging
|
||||
- 🔌 **Extensible**: Clean interfaces for adding new notifiers
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install and Run
|
||||
|
||||
```bash
|
||||
# Clone and install dependencies
|
||||
git clone https://github.com/igodwin/notifier
|
||||
cd notifier
|
||||
go mod tidy
|
||||
|
||||
# Build and run
|
||||
go build -o bin/server ./cmd/server
|
||||
./bin/server
|
||||
```
|
||||
|
||||
Server starts with:
|
||||
- **REST API** on `http://localhost:8080`
|
||||
- **gRPC API** on `localhost:50051`
|
||||
- **Stdout notifier** enabled by default
|
||||
|
||||
### 2. Send Your First Notification
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "stdout",
|
||||
"subject": "Hello from Notifier!",
|
||||
"body": "Your first notification",
|
||||
"recipients": ["console"]
|
||||
}'
|
||||
```
|
||||
|
||||
The notification appears immediately in the server output. ✅
|
||||
|
||||
### 3. Check Status
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# Statistics
|
||||
curl http://localhost:8080/api/v1/stats
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
Create `config.yaml` in the project root:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
mode: "both" # Options: both, rest, grpc
|
||||
rest_port: 8080
|
||||
grpc_port: 50051
|
||||
host: "0.0.0.0"
|
||||
|
||||
queue:
|
||||
type: "local" # Local in-memory queue
|
||||
worker_count: 10 # Concurrent workers
|
||||
retry_attempts: 3
|
||||
retry_backoff: "exponential"
|
||||
|
||||
notifiers:
|
||||
stdout: true # Always enabled for testing
|
||||
```
|
||||
|
||||
### Email Notifications (SMTP)
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
username: "your-email@gmail.com"
|
||||
password: "your-app-password"
|
||||
from: "notifications@yourservice.com"
|
||||
use_tls: true
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "email",
|
||||
"subject": "Welcome!",
|
||||
"body": "Thanks for signing up",
|
||||
"recipients": ["user@example.com"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Slack Notifications
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
slack:
|
||||
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
|
||||
username: "Notifier Bot"
|
||||
icon_emoji: ":bell:"
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "slack",
|
||||
"subject": "Deployment Complete",
|
||||
"body": "v2.0 deployed to production",
|
||||
"recipients": ["#alerts"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Ntfy Push Notifications
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
token: "tk_your_access_token" # Optional, for private topics
|
||||
default_topic: "myapp-alerts"
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"priority": 3,
|
||||
"subject": "Critical Alert",
|
||||
"body": "Server CPU at 95%",
|
||||
"recipients": ["alerts"],
|
||||
"metadata": {
|
||||
"tags": ["warning", "rotating_light"],
|
||||
"click": "https://dashboard.example.com"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
See [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) for advanced ntfy features (action buttons, attachments, delays, etc.).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Override any config with environment variables:
|
||||
|
||||
```bash
|
||||
export NOTIFIER_SERVER_MODE=both
|
||||
export NOTIFIER_SERVER_REST_PORT=8080
|
||||
export NOTIFIER_QUEUE_WORKER_COUNT=20
|
||||
export NOTIFIER_NOTIFIERS_SMTP_HOST=smtp.gmail.com
|
||||
export NOTIFIER_NOTIFIERS_SMTP_PASSWORD=secret
|
||||
export NOTIFIER_NOTIFIERS_NTFY_TOKEN=tk_your_token
|
||||
```
|
||||
|
||||
Format: `NOTIFIER_<SECTION>_<KEY>` (use `_` for nested keys)
|
||||
|
||||
## API Reference
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/health` | Health check |
|
||||
| `POST` | `/api/v1/notifications` | Send single notification |
|
||||
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications |
|
||||
| `GET` | `/api/v1/notifications` | List notifications (with filters) |
|
||||
| `GET` | `/api/v1/notifications/{id}` | Get notification by ID |
|
||||
| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification |
|
||||
| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed notification |
|
||||
| `GET` | `/api/v1/stats` | Get service statistics |
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "email|slack|ntfy|stdout",
|
||||
"priority": 0-3,
|
||||
"subject": "Notification title",
|
||||
"body": "Notification body",
|
||||
"recipients": ["email@example.com", "#channel", "topic"],
|
||||
"metadata": {
|
||||
"key": "value"
|
||||
},
|
||||
"max_retries": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"notification_id": "uuid",
|
||||
"success": true,
|
||||
"message": "notification queued successfully",
|
||||
"sent_at": "2025-10-16T21:05:27Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Priority Levels
|
||||
|
||||
- `0` - Low (background notifications)
|
||||
- `1` - Normal (default)
|
||||
- `2` - High (important updates)
|
||||
- `3` - Critical (urgent alerts)
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications/batch \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"notifications": [
|
||||
{"type": "email", "subject": "Welcome", ...},
|
||||
{"type": "slack", "subject": "Alert", ...}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Filtering Notifications
|
||||
|
||||
```bash
|
||||
# Get all sent email notifications
|
||||
curl "http://localhost:8080/api/v1/notifications?type=email&status=sent&limit=10"
|
||||
|
||||
# Get recent failures
|
||||
curl "http://localhost:8080/api/v1/notifications?status=failed&limit=20"
|
||||
```
|
||||
|
||||
## gRPC API
|
||||
|
||||
The gRPC service mirrors the REST API with full feature parity. See [api/grpc/notifier.proto](api/grpc/notifier.proto) for definitions.
|
||||
|
||||
**Generate Go code:**
|
||||
```bash
|
||||
make proto-gen
|
||||
# or
|
||||
protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto
|
||||
```
|
||||
|
||||
**Note:** gRPC server is running but handler implementation is pending. Protobuf definitions are complete.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
**Build:**
|
||||
```bash
|
||||
docker build -t notifier:latest .
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name notifier \
|
||||
-p 8080:8080 \
|
||||
-p 50051:50051 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml:ro \
|
||||
notifier:latest
|
||||
```
|
||||
|
||||
**Docker Compose:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Includes optional services: Kafka, Prometheus, Grafana (commented out by default)
|
||||
|
||||
### Kubernetes
|
||||
|
||||
**Deploy:**
|
||||
```bash
|
||||
kubectl apply -f k8s/
|
||||
```
|
||||
|
||||
**Included resources:**
|
||||
- Deployment (3 replicas with rolling updates)
|
||||
- Services (separate for REST and gRPC)
|
||||
- ConfigMap (configuration management)
|
||||
- HPA (auto-scaling 3-10 pods based on CPU/memory)
|
||||
- Ingress (external access with TLS)
|
||||
- Secret template (for credentials)
|
||||
|
||||
**Access locally:**
|
||||
```bash
|
||||
kubectl port-forward svc/notifier-rest 8080:8080
|
||||
kubectl port-forward svc/notifier-grpc 50051:50051
|
||||
```
|
||||
|
||||
**Using Kustomize:**
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level Design
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ REST Client │────▶│ REST API │
|
||||
└──────────────┘ │ (port 8080) │
|
||||
└──────┬───────┘
|
||||
┌──────────────┐ │
|
||||
│ gRPC Client │────▶│ gRPC API │
|
||||
└──────────────┘ │ (port 50051) │
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Notification │
|
||||
│ Service │
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Queue (Local) │◀──┐
|
||||
└────────┬─────────┘ │
|
||||
▼ │
|
||||
┌──────────────────┐ │
|
||||
│ Worker Pool │───┘
|
||||
│ (10 workers) │
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ SMTP │ │ Slack │ │ Ntfy │
|
||||
│Notifier│ │ Notifier │ │ Notifier │
|
||||
└────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
- **API Layer**: REST (Gorilla mux) and gRPC (Protocol Buffers)
|
||||
- **Service Layer**: Business logic, validation, orchestration
|
||||
- **Queue**: In-memory with optional disk persistence (Kafka planned)
|
||||
- **Workers**: Concurrent processors with retry logic
|
||||
- **Notifiers**: Pluggable providers implementing `domain.Notifier`
|
||||
- **Config**: Viper-based with file + env var support
|
||||
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design documentation.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
```
|
||||
notifier/
|
||||
├── api/
|
||||
│ ├── grpc/ # gRPC service definitions and generated code
|
||||
│ └── rest/ # REST API handlers and router
|
||||
│ ├── grpc/
|
||||
│ │ └── notifier.proto # gRPC service definition
|
||||
│ └── rest/
|
||||
│ ├── handlers.go # HTTP handlers
|
||||
│ ├── router.go # Route configuration
|
||||
│ └── types.go # Request/response types
|
||||
├── cmd/
|
||||
│ ├── grpcserver/ # Entrypoint for running the gRPC server
|
||||
│ └── restserver/ # Entrypoint for running the REST server
|
||||
│ ├── server/main.go # Combined server (recommended)
|
||||
│ ├── grpcserver/main.go # gRPC only
|
||||
│ └── restserver/main.go # REST only
|
||||
├── internal/
|
||||
│ ├── config/ # Configuration loading
|
||||
│ └── notifier/ # Core notification logic with multiple implementations
|
||||
├── pkg/
|
||||
│ ├── grpcclient/ # gRPC client for interacting with the notifier service
|
||||
│ └── restclient/ # REST client for interacting with the notifier service
|
||||
├── Dockerfile # Docker configuration for deploying as a microservice
|
||||
├── LICENSE # License file
|
||||
├── README.md # Documentation
|
||||
└── go.mod # Defines the module's dependencies, module path, and Go version
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Configuration management
|
||||
│ ├── domain/
|
||||
│ │ ├── notification.go # Core types
|
||||
│ │ ├── notifier.go # Notifier interface
|
||||
│ │ └── queue.go # Queue interface
|
||||
│ ├── notifier/
|
||||
│ │ ├── notifier.go # Factory & base
|
||||
│ │ ├── smtp.go # Email notifier
|
||||
│ │ ├── slack.go # Slack notifier
|
||||
│ │ ├── ntfy.go # Ntfy notifier
|
||||
│ │ └── stdout.go # Stdout notifier
|
||||
│ ├── queue/
|
||||
│ │ └── local.go # In-memory queue
|
||||
│ └── service/
|
||||
│ └── service.go # Business logic
|
||||
├── k8s/
|
||||
│ ├── deployment.yaml
|
||||
│ ├── service.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ ├── hpa.yaml
|
||||
│ └── kustomization.yaml
|
||||
├── docs/
|
||||
│ └── NTFY_GUIDE.md # Ntfy integration guide
|
||||
├── config.yaml # Default configuration
|
||||
├── docker-compose.yaml
|
||||
├── Dockerfile
|
||||
├── Makefile
|
||||
├── QUICKSTART.md # API examples
|
||||
├── ARCHITECTURE.md # Design documentation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go**: Install Go 1.18 or higher.
|
||||
- **Protocol Buffers**: Required if you want to regenerate gRPC code.
|
||||
- **Docker** (optional): For containerized deployment.
|
||||
|
||||
### Installation
|
||||
|
||||
To install the notifier module as a dependency in another Go project, run:
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
go get github.com/igodwin/notifier
|
||||
make build # Build binaries
|
||||
make run-rest # Run REST server
|
||||
make run-grpc # Run gRPC server (when implemented)
|
||||
make test # Run tests
|
||||
make lint # Run linter
|
||||
make proto-gen # Generate protobuf code
|
||||
make docker-build # Build Docker image
|
||||
make clean # Clean artifacts
|
||||
```
|
||||
|
||||
### Running the Service
|
||||
### Adding a New Notifier
|
||||
|
||||
#### gRPC Server
|
||||
|
||||
To start the gRPC server:
|
||||
|
||||
```bash
|
||||
cd cmd/grpcserver
|
||||
go run main.go
|
||||
```
|
||||
|
||||
#### REST Server
|
||||
|
||||
To start the REST server:
|
||||
|
||||
```bash
|
||||
cd cmd/restserver
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Configure notification modes via environment variables or a configuration file (e.g., `config.yaml`). Configuration settings may include:
|
||||
|
||||
- **SMTP**: SMTP server details, port, credentials.
|
||||
- **SMS**: SMS provider API keys.
|
||||
- **Slack**: Slack webhook URLs.
|
||||
- **Ntfy**: Ntfy service URL and options.
|
||||
|
||||
For example, using a `config.yaml`:
|
||||
|
||||
```yaml
|
||||
smtp:
|
||||
server: "smtp.example.com"
|
||||
port: 587
|
||||
username: "user@example.com"
|
||||
password: "password"
|
||||
|
||||
slack:
|
||||
webhook_url: "https://hooks.slack.com/services/..."
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
#### Using gRPC API
|
||||
|
||||
1. Define a gRPC client using the `pkg/grpcclient` package.
|
||||
2. Call `SendNotification` to send a message through a chosen notification method.
|
||||
1. Create `internal/notifier/mynotifier.go`
|
||||
2. Implement `domain.Notifier` interface
|
||||
3. Add config struct to `internal/config/config.go`
|
||||
4. Register in `cmd/server/main.go`
|
||||
5. Update `config.yaml` with example config
|
||||
6. Add tests
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
client, err := grpcclient.NewClient("localhost:50051")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create gRPC client: %v", err)
|
||||
type MyNotifier struct {
|
||||
BaseNotifier
|
||||
config *MyNotifierConfig
|
||||
}
|
||||
|
||||
resp, err := client.SendNotification("recipient@example.com", "Hello via gRPC!")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to send notification: %v", err)
|
||||
func (m *MyNotifier) Send(ctx context.Context, n *domain.Notification) (*domain.NotificationResult, error) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
#### Using REST API
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed guide.
|
||||
|
||||
1. Define a REST client using the `pkg/restclient` package.
|
||||
2. Call `SendNotification` via REST.
|
||||
## Documentation
|
||||
|
||||
Example:
|
||||
- [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details
|
||||
- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration guide
|
||||
- [api/grpc/notifier.proto](api/grpc/notifier.proto) - gRPC API definition
|
||||
|
||||
```go
|
||||
client := restclient.NewClient("http://localhost:8080")
|
||||
err := client.SendNotification("recipient@example.com", "Hello via REST!")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to send notification: %v", err)
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/notifier/...
|
||||
```
|
||||
|
||||
**Current Status:** Core implementation complete, tests pending.
|
||||
|
||||
## Monitoring & Operations
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"service": "notifier",
|
||||
"time": "2025-10-16T21:05:27Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Implementing New Notification Modes
|
||||
### Statistics
|
||||
|
||||
To add a new notification method, implement the `Notifier` interface in `internal/notifier/`:
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/stats
|
||||
```
|
||||
|
||||
```go
|
||||
type Notifier interface {
|
||||
Send(notification Notification) error
|
||||
Returns:
|
||||
```json
|
||||
{
|
||||
"total_sent": 1234,
|
||||
"total_failed": 5,
|
||||
"total_pending": 0,
|
||||
"total_queued": 2,
|
||||
"by_type": {
|
||||
"email": 800,
|
||||
"slack": 400,
|
||||
"ntfy": 34
|
||||
},
|
||||
"by_status": {
|
||||
"sent": 1234,
|
||||
"failed": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the new mode (e.g., `sms.go`) with the `Send` method to implement custom logic.
|
||||
### Graceful Shutdown
|
||||
|
||||
The server handles `SIGINT` and `SIGTERM` gracefully:
|
||||
- Stops accepting new requests
|
||||
- Completes in-flight notifications
|
||||
- Drains the queue
|
||||
- Closes all connections
|
||||
- 30-second timeout
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed ✅
|
||||
- [x] Core notification system
|
||||
- [x] REST API (fully functional)
|
||||
- [x] gRPC API (protobuf defined)
|
||||
- [x] Local queue with workers
|
||||
- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout)
|
||||
- [x] Priority and retry logic
|
||||
- [x] Batch operations
|
||||
- [x] Docker support
|
||||
- [x] Kubernetes manifests with HPA
|
||||
- [x] Health checks and stats
|
||||
- [x] Configuration management
|
||||
|
||||
### Planned 🚧
|
||||
- [ ] gRPC handler implementation
|
||||
- [ ] Kafka queue adapter
|
||||
- [ ] Database persistence (PostgreSQL)
|
||||
- [ ] Notification templates
|
||||
- [ ] Webhook callbacks
|
||||
- [ ] Authentication/Authorization (API keys, OAuth)
|
||||
- [ ] Rate limiting (per client, per notifier)
|
||||
- [ ] Prometheus metrics
|
||||
- [ ] OpenTelemetry tracing
|
||||
- [ ] Comprehensive test suite
|
||||
- [ ] Circuit breakers for notifiers
|
||||
- [ ] Dead letter queue
|
||||
- [ ] Admin dashboard
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! To contribute, fork the repository, make your changes, and submit a pull request.
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch (`git checkout -b feature/NewMode`).
|
||||
3. Commit your changes (`git commit -am 'Add new notification mode'`).
|
||||
4. Push to the branch (`git push origin feature/NewMode`).
|
||||
5. Create a new Pull Request.
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Write tests for your changes
|
||||
4. Ensure tests pass (`go test ./...`)
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
Please follow Go best practices and maintain test coverage.
|
||||
|
||||
## Security
|
||||
|
||||
### Reporting Vulnerabilities
|
||||
|
||||
Please report security vulnerabilities privately via GitHub Security Advisories.
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Store credentials in environment variables or secrets managers
|
||||
- Use TLS for production deployments
|
||||
- Enable authentication for ntfy private topics
|
||||
- Rotate tokens regularly
|
||||
- Follow principle of least privilege for K8s RBAC
|
||||
- Keep dependencies updated
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Support & Community
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/igodwin/notifier/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/igodwin/notifier/discussions)
|
||||
- **Documentation**: See `docs/` directory
|
||||
- **Examples**: See [QUICKSTART.md](QUICKSTART.md)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
- [Gorilla Mux](https://github.com/gorilla/mux) - HTTP routing
|
||||
- [Viper](https://github.com/spf13/viper) - Configuration
|
||||
- [gRPC-Go](https://github.com/grpc/grpc-go) - RPC framework
|
||||
- [Ntfy](https://ntfy.sh) - Push notifications
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for reliable notifications**
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package notifier.v1;
|
||||
|
||||
option go_package = "github.com/igodwin/notifier/api/grpc/pb";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// NotifierService handles notification operations
|
||||
service NotifierService {
|
||||
// SendNotification sends a single notification
|
||||
rpc SendNotification(SendNotificationRequest) returns (SendNotificationResponse);
|
||||
|
||||
// SendBatchNotifications sends multiple notifications
|
||||
rpc SendBatchNotifications(SendBatchNotificationsRequest) returns (SendBatchNotificationsResponse);
|
||||
|
||||
// GetNotification retrieves a notification by ID
|
||||
rpc GetNotification(GetNotificationRequest) returns (GetNotificationResponse);
|
||||
|
||||
// ListNotifications retrieves notifications matching a filter
|
||||
rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);
|
||||
|
||||
// CancelNotification cancels a pending notification
|
||||
rpc CancelNotification(CancelNotificationRequest) returns (CancelNotificationResponse);
|
||||
|
||||
// RetryNotification retries a failed notification
|
||||
rpc RetryNotification(RetryNotificationRequest) returns (RetryNotificationResponse);
|
||||
|
||||
// GetStats returns notification statistics
|
||||
rpc GetStats(GetStatsRequest) returns (GetStatsResponse);
|
||||
|
||||
// HealthCheck verifies the service is operational
|
||||
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
}
|
||||
|
||||
// NotificationType defines the channel for notification delivery
|
||||
enum NotificationType {
|
||||
NOTIFICATION_TYPE_UNSPECIFIED = 0;
|
||||
NOTIFICATION_TYPE_EMAIL = 1;
|
||||
NOTIFICATION_TYPE_SLACK = 2;
|
||||
NOTIFICATION_TYPE_NTFY = 3;
|
||||
NOTIFICATION_TYPE_STDOUT = 4;
|
||||
}
|
||||
|
||||
// Priority defines the urgency level
|
||||
enum Priority {
|
||||
PRIORITY_UNSPECIFIED = 0;
|
||||
PRIORITY_LOW = 1;
|
||||
PRIORITY_NORMAL = 2;
|
||||
PRIORITY_HIGH = 3;
|
||||
PRIORITY_CRITICAL = 4;
|
||||
}
|
||||
|
||||
// NotificationStatus represents the state of a notification
|
||||
enum NotificationStatus {
|
||||
NOTIFICATION_STATUS_UNSPECIFIED = 0;
|
||||
NOTIFICATION_STATUS_PENDING = 1;
|
||||
NOTIFICATION_STATUS_QUEUED = 2;
|
||||
NOTIFICATION_STATUS_PROCESSING = 3;
|
||||
NOTIFICATION_STATUS_SENT = 4;
|
||||
NOTIFICATION_STATUS_FAILED = 5;
|
||||
NOTIFICATION_STATUS_RETRYING = 6;
|
||||
}
|
||||
|
||||
// Notification represents a notification message
|
||||
message Notification {
|
||||
string id = 1;
|
||||
NotificationType type = 2;
|
||||
Priority priority = 3;
|
||||
NotificationStatus status = 4;
|
||||
string subject = 5;
|
||||
string body = 6;
|
||||
repeated string recipients = 7;
|
||||
map<string, string> metadata = 8;
|
||||
google.protobuf.Timestamp created_at = 9;
|
||||
google.protobuf.Timestamp scheduled_for = 10;
|
||||
google.protobuf.Timestamp sent_at = 11;
|
||||
int32 retry_count = 12;
|
||||
int32 max_retries = 13;
|
||||
string last_error = 14;
|
||||
}
|
||||
|
||||
// NotificationResult represents the outcome of sending a notification
|
||||
message NotificationResult {
|
||||
string notification_id = 1;
|
||||
bool success = 2;
|
||||
string message = 3;
|
||||
string error = 4;
|
||||
google.protobuf.Timestamp sent_at = 5;
|
||||
map<string, string> provider_response = 6;
|
||||
}
|
||||
|
||||
// SendNotificationRequest sends a single notification
|
||||
message SendNotificationRequest {
|
||||
NotificationType type = 1;
|
||||
Priority priority = 2;
|
||||
string subject = 3;
|
||||
string body = 4;
|
||||
repeated string recipients = 5;
|
||||
map<string, string> metadata = 6;
|
||||
google.protobuf.Timestamp scheduled_for = 7;
|
||||
int32 max_retries = 8;
|
||||
}
|
||||
|
||||
// SendNotificationResponse returns the result of sending a notification
|
||||
message SendNotificationResponse {
|
||||
NotificationResult result = 1;
|
||||
}
|
||||
|
||||
// SendBatchNotificationsRequest sends multiple notifications
|
||||
message SendBatchNotificationsRequest {
|
||||
repeated SendNotificationRequest notifications = 1;
|
||||
}
|
||||
|
||||
// SendBatchNotificationsResponse returns the results of sending multiple notifications
|
||||
message SendBatchNotificationsResponse {
|
||||
repeated NotificationResult results = 1;
|
||||
}
|
||||
|
||||
// GetNotificationRequest retrieves a notification by ID
|
||||
message GetNotificationRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// GetNotificationResponse returns a notification
|
||||
message GetNotificationResponse {
|
||||
Notification notification = 1;
|
||||
}
|
||||
|
||||
// NotificationFilter is used for querying notifications
|
||||
message NotificationFilter {
|
||||
repeated string ids = 1;
|
||||
repeated NotificationType types = 2;
|
||||
repeated NotificationStatus statuses = 3;
|
||||
repeated string recipients = 4;
|
||||
google.protobuf.Timestamp created_after = 5;
|
||||
google.protobuf.Timestamp created_before = 6;
|
||||
int32 limit = 7;
|
||||
int32 offset = 8;
|
||||
}
|
||||
|
||||
// ListNotificationsRequest retrieves notifications matching a filter
|
||||
message ListNotificationsRequest {
|
||||
NotificationFilter filter = 1;
|
||||
}
|
||||
|
||||
// ListNotificationsResponse returns a list of notifications
|
||||
message ListNotificationsResponse {
|
||||
repeated Notification notifications = 1;
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
// CancelNotificationRequest cancels a pending notification
|
||||
message CancelNotificationRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// CancelNotificationResponse returns the result of canceling a notification
|
||||
message CancelNotificationResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// RetryNotificationRequest retries a failed notification
|
||||
message RetryNotificationRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// RetryNotificationResponse returns the result of retrying a notification
|
||||
message RetryNotificationResponse {
|
||||
NotificationResult result = 1;
|
||||
}
|
||||
|
||||
// GetStatsRequest requests notification statistics
|
||||
message GetStatsRequest {}
|
||||
|
||||
// GetStatsResponse returns notification statistics
|
||||
message GetStatsResponse {
|
||||
int64 total_sent = 1;
|
||||
int64 total_failed = 2;
|
||||
int64 total_pending = 3;
|
||||
int64 total_queued = 4;
|
||||
map<string, int64> by_type = 5;
|
||||
map<string, int64> by_status = 6;
|
||||
double average_latency_ms = 7;
|
||||
}
|
||||
|
||||
// HealthCheckRequest requests health status
|
||||
message HealthCheckRequest {}
|
||||
|
||||
// HealthCheckResponse returns health status
|
||||
message HealthCheckResponse {
|
||||
bool healthy = 1;
|
||||
string status = 2;
|
||||
map<string, string> components = 3;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v6.33.0
|
||||
// source: api/grpc/notifier.proto
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
NotifierService_SendNotification_FullMethodName = "/notifier.v1.NotifierService/SendNotification"
|
||||
NotifierService_SendBatchNotifications_FullMethodName = "/notifier.v1.NotifierService/SendBatchNotifications"
|
||||
NotifierService_GetNotification_FullMethodName = "/notifier.v1.NotifierService/GetNotification"
|
||||
NotifierService_ListNotifications_FullMethodName = "/notifier.v1.NotifierService/ListNotifications"
|
||||
NotifierService_CancelNotification_FullMethodName = "/notifier.v1.NotifierService/CancelNotification"
|
||||
NotifierService_RetryNotification_FullMethodName = "/notifier.v1.NotifierService/RetryNotification"
|
||||
NotifierService_GetStats_FullMethodName = "/notifier.v1.NotifierService/GetStats"
|
||||
NotifierService_HealthCheck_FullMethodName = "/notifier.v1.NotifierService/HealthCheck"
|
||||
)
|
||||
|
||||
// NotifierServiceClient is the client API for NotifierService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// NotifierService handles notification operations
|
||||
type NotifierServiceClient interface {
|
||||
// SendNotification sends a single notification
|
||||
SendNotification(ctx context.Context, in *SendNotificationRequest, opts ...grpc.CallOption) (*SendNotificationResponse, error)
|
||||
// SendBatchNotifications sends multiple notifications
|
||||
SendBatchNotifications(ctx context.Context, in *SendBatchNotificationsRequest, opts ...grpc.CallOption) (*SendBatchNotificationsResponse, error)
|
||||
// GetNotification retrieves a notification by ID
|
||||
GetNotification(ctx context.Context, in *GetNotificationRequest, opts ...grpc.CallOption) (*GetNotificationResponse, error)
|
||||
// ListNotifications retrieves notifications matching a filter
|
||||
ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*ListNotificationsResponse, error)
|
||||
// CancelNotification cancels a pending notification
|
||||
CancelNotification(ctx context.Context, in *CancelNotificationRequest, opts ...grpc.CallOption) (*CancelNotificationResponse, error)
|
||||
// RetryNotification retries a failed notification
|
||||
RetryNotification(ctx context.Context, in *RetryNotificationRequest, opts ...grpc.CallOption) (*RetryNotificationResponse, error)
|
||||
// GetStats returns notification statistics
|
||||
GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error)
|
||||
// HealthCheck verifies the service is operational
|
||||
HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error)
|
||||
}
|
||||
|
||||
type notifierServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewNotifierServiceClient(cc grpc.ClientConnInterface) NotifierServiceClient {
|
||||
return ¬ifierServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) SendNotification(ctx context.Context, in *SendNotificationRequest, opts ...grpc.CallOption) (*SendNotificationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SendNotificationResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_SendNotification_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) SendBatchNotifications(ctx context.Context, in *SendBatchNotificationsRequest, opts ...grpc.CallOption) (*SendBatchNotificationsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SendBatchNotificationsResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_SendBatchNotifications_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) GetNotification(ctx context.Context, in *GetNotificationRequest, opts ...grpc.CallOption) (*GetNotificationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetNotificationResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_GetNotification_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*ListNotificationsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListNotificationsResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_ListNotifications_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) CancelNotification(ctx context.Context, in *CancelNotificationRequest, opts ...grpc.CallOption) (*CancelNotificationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CancelNotificationResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_CancelNotification_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) RetryNotification(ctx context.Context, in *RetryNotificationRequest, opts ...grpc.CallOption) (*RetryNotificationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RetryNotificationResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_RetryNotification_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetStatsResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_GetStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notifierServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HealthCheckResponse)
|
||||
err := c.cc.Invoke(ctx, NotifierService_HealthCheck_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NotifierServiceServer is the server API for NotifierService service.
|
||||
// All implementations must embed UnimplementedNotifierServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// NotifierService handles notification operations
|
||||
type NotifierServiceServer interface {
|
||||
// SendNotification sends a single notification
|
||||
SendNotification(context.Context, *SendNotificationRequest) (*SendNotificationResponse, error)
|
||||
// SendBatchNotifications sends multiple notifications
|
||||
SendBatchNotifications(context.Context, *SendBatchNotificationsRequest) (*SendBatchNotificationsResponse, error)
|
||||
// GetNotification retrieves a notification by ID
|
||||
GetNotification(context.Context, *GetNotificationRequest) (*GetNotificationResponse, error)
|
||||
// ListNotifications retrieves notifications matching a filter
|
||||
ListNotifications(context.Context, *ListNotificationsRequest) (*ListNotificationsResponse, error)
|
||||
// CancelNotification cancels a pending notification
|
||||
CancelNotification(context.Context, *CancelNotificationRequest) (*CancelNotificationResponse, error)
|
||||
// RetryNotification retries a failed notification
|
||||
RetryNotification(context.Context, *RetryNotificationRequest) (*RetryNotificationResponse, error)
|
||||
// GetStats returns notification statistics
|
||||
GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error)
|
||||
// HealthCheck verifies the service is operational
|
||||
HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error)
|
||||
mustEmbedUnimplementedNotifierServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedNotifierServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedNotifierServiceServer struct{}
|
||||
|
||||
func (UnimplementedNotifierServiceServer) SendNotification(context.Context, *SendNotificationRequest) (*SendNotificationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SendNotification not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) SendBatchNotifications(context.Context, *SendBatchNotificationsRequest) (*SendBatchNotificationsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SendBatchNotifications not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) GetNotification(context.Context, *GetNotificationRequest) (*GetNotificationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetNotification not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) ListNotifications(context.Context, *ListNotificationsRequest) (*ListNotificationsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListNotifications not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) CancelNotification(context.Context, *CancelNotificationRequest) (*CancelNotificationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CancelNotification not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) RetryNotification(context.Context, *RetryNotificationRequest) (*RetryNotificationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RetryNotification not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method HealthCheck not implemented")
|
||||
}
|
||||
func (UnimplementedNotifierServiceServer) mustEmbedUnimplementedNotifierServiceServer() {}
|
||||
func (UnimplementedNotifierServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeNotifierServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to NotifierServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeNotifierServiceServer interface {
|
||||
mustEmbedUnimplementedNotifierServiceServer()
|
||||
}
|
||||
|
||||
func RegisterNotifierServiceServer(s grpc.ServiceRegistrar, srv NotifierServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedNotifierServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&NotifierService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _NotifierService_SendNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SendNotificationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).SendNotification(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_SendNotification_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).SendNotification(ctx, req.(*SendNotificationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_SendBatchNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SendBatchNotificationsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).SendBatchNotifications(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_SendBatchNotifications_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).SendBatchNotifications(ctx, req.(*SendBatchNotificationsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_GetNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetNotificationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).GetNotification(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_GetNotification_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).GetNotification(ctx, req.(*GetNotificationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_ListNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListNotificationsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).ListNotifications(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_ListNotifications_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).ListNotifications(ctx, req.(*ListNotificationsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_CancelNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CancelNotificationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).CancelNotification(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_CancelNotification_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).CancelNotification(ctx, req.(*CancelNotificationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_RetryNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RetryNotificationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).RetryNotification(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_RetryNotification_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).RetryNotification(ctx, req.(*RetryNotificationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).GetStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_GetStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).GetStats(ctx, req.(*GetStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NotifierService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(HealthCheckRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotifierServiceServer).HealthCheck(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: NotifierService_HealthCheck_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotifierServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// NotifierService_ServiceDesc is the grpc.ServiceDesc for NotifierService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var NotifierService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "notifier.v1.NotifierService",
|
||||
HandlerType: (*NotifierServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "SendNotification",
|
||||
Handler: _NotifierService_SendNotification_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SendBatchNotifications",
|
||||
Handler: _NotifierService_SendBatchNotifications_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetNotification",
|
||||
Handler: _NotifierService_GetNotification_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListNotifications",
|
||||
Handler: _NotifierService_ListNotifications_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CancelNotification",
|
||||
Handler: _NotifierService_CancelNotification_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RetryNotification",
|
||||
Handler: _NotifierService_RetryNotification_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetStats",
|
||||
Handler: _NotifierService_GetStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "HealthCheck",
|
||||
Handler: _NotifierService_HealthCheck_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/grpc/notifier.proto",
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// Handler handles REST API requests
|
||||
type Handler struct {
|
||||
service domain.NotificationService
|
||||
}
|
||||
|
||||
// NewHandler creates a new REST handler
|
||||
func NewHandler(service domain.NotificationService) *Handler {
|
||||
return &Handler{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
// SendNotification handles POST /api/v1/notifications
|
||||
func (h *Handler) SendNotification(w http.ResponseWriter, r *http.Request) {
|
||||
var req SendNotificationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := req.Validate(); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "validation failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to domain notification
|
||||
notification := req.ToNotification()
|
||||
|
||||
// Send notification
|
||||
result, err := h.service.Send(r.Context(), notification)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to send notification", err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusAccepted, SendNotificationResponse{
|
||||
Result: NotificationResultFromDomain(result),
|
||||
})
|
||||
}
|
||||
|
||||
// SendBatchNotifications handles POST /api/v1/notifications/batch
|
||||
func (h *Handler) SendBatchNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
var req SendBatchNotificationsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate and convert to domain notifications
|
||||
notifications := make([]*domain.Notification, 0, len(req.Notifications))
|
||||
for _, notifReq := range req.Notifications {
|
||||
if err := notifReq.Validate(); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "validation failed", err)
|
||||
return
|
||||
}
|
||||
notifications = append(notifications, notifReq.ToNotification())
|
||||
}
|
||||
|
||||
// Send batch
|
||||
results, err := h.service.SendBatch(r.Context(), notifications)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to send batch notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert results
|
||||
apiResults := make([]NotificationResult, 0, len(results))
|
||||
for _, result := range results {
|
||||
apiResults = append(apiResults, NotificationResultFromDomain(result))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusAccepted, SendBatchNotificationsResponse{
|
||||
Results: apiResults,
|
||||
})
|
||||
}
|
||||
|
||||
// GetNotification handles GET /api/v1/notifications/{id}
|
||||
func (h *Handler) GetNotification(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
notification, err := h.service.GetNotification(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "notification not found", err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, NotificationFromDomain(notification))
|
||||
}
|
||||
|
||||
// ListNotifications handles GET /api/v1/notifications
|
||||
func (h *Handler) ListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
filter := parseNotificationFilter(r)
|
||||
|
||||
notifications, err := h.service.ListNotifications(r.Context(), filter)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
apiNotifications := make([]Notification, 0, len(notifications))
|
||||
for _, notif := range notifications {
|
||||
apiNotifications = append(apiNotifications, NotificationFromDomain(notif))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, ListNotificationsResponse{
|
||||
Notifications: apiNotifications,
|
||||
Total: int64(len(apiNotifications)),
|
||||
})
|
||||
}
|
||||
|
||||
// CancelNotification handles DELETE /api/v1/notifications/{id}
|
||||
func (h *Handler) CancelNotification(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if err := h.service.CancelNotification(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to cancel notification", err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "notification canceled successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// RetryNotification handles POST /api/v1/notifications/{id}/retry
|
||||
func (h *Handler) RetryNotification(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
result, err := h.service.RetryNotification(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to retry notification", err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, RetryNotificationResponse{
|
||||
Result: NotificationResultFromDomain(result),
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats handles GET /api/v1/stats
|
||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.service.GetStats(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get stats", err)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// HealthCheck handles GET /health
|
||||
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"service": "notifier",
|
||||
"time": time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// parseNotificationFilter parses query parameters into a NotificationFilter
|
||||
func parseNotificationFilter(r *http.Request) *domain.NotificationFilter {
|
||||
query := r.URL.Query()
|
||||
filter := &domain.NotificationFilter{}
|
||||
|
||||
// Parse limit
|
||||
if limitStr := query.Get("limit"); limitStr != "" {
|
||||
if limit, err := strconv.Atoi(limitStr); err == nil {
|
||||
filter.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
// Parse offset
|
||||
if offsetStr := query.Get("offset"); offsetStr != "" {
|
||||
if offset, err := strconv.Atoi(offsetStr); err == nil {
|
||||
filter.Offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// Parse types
|
||||
if types := query["type"]; len(types) > 0 {
|
||||
for _, t := range types {
|
||||
filter.Types = append(filter.Types, domain.NotificationType(t))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse statuses
|
||||
if statuses := query["status"]; len(statuses) > 0 {
|
||||
for _, s := range statuses {
|
||||
filter.Statuses = append(filter.Statuses, domain.NotificationStatus(s))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse recipients
|
||||
if recipients := query["recipient"]; len(recipients) > 0 {
|
||||
filter.Recipients = recipients
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// respondJSON sends a JSON response
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// respondError sends an error response
|
||||
func respondError(w http.ResponseWriter, status int, message string, err error) {
|
||||
errMsg := message
|
||||
if err != nil {
|
||||
errMsg = message + ": " + err.Error()
|
||||
}
|
||||
|
||||
respondJSON(w, status, map[string]interface{}{
|
||||
"error": message,
|
||||
"details": errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// NewRouter creates a new HTTP router with all routes configured
|
||||
func NewRouter(service domain.NotificationService) *mux.Router {
|
||||
handler := NewHandler(service)
|
||||
router := mux.NewRouter()
|
||||
|
||||
// API v1 routes
|
||||
v1 := router.PathPrefix("/api/v1").Subrouter()
|
||||
|
||||
// Notification routes
|
||||
v1.HandleFunc("/notifications", handler.SendNotification).Methods(http.MethodPost)
|
||||
v1.HandleFunc("/notifications/batch", handler.SendBatchNotifications).Methods(http.MethodPost)
|
||||
v1.HandleFunc("/notifications", handler.ListNotifications).Methods(http.MethodGet)
|
||||
v1.HandleFunc("/notifications/{id}", handler.GetNotification).Methods(http.MethodGet)
|
||||
v1.HandleFunc("/notifications/{id}", handler.CancelNotification).Methods(http.MethodDelete)
|
||||
v1.HandleFunc("/notifications/{id}/retry", handler.RetryNotification).Methods(http.MethodPost)
|
||||
|
||||
// Stats route
|
||||
v1.HandleFunc("/stats", handler.GetStats).Methods(http.MethodGet)
|
||||
|
||||
// Health check route
|
||||
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
||||
|
||||
// Middleware
|
||||
router.Use(loggingMiddleware)
|
||||
router.Use(corsMiddleware)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// loggingMiddleware logs incoming requests
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// You can add structured logging here
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// SendNotificationRequest is the REST API request for sending a notification
|
||||
type SendNotificationRequest struct {
|
||||
Type string `json:"type"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates the request
|
||||
func (r *SendNotificationRequest) Validate() error {
|
||||
if r.Type == "" {
|
||||
return fmt.Errorf("type is required")
|
||||
}
|
||||
|
||||
if len(r.Recipients) == 0 {
|
||||
return fmt.Errorf("at least one recipient is required")
|
||||
}
|
||||
|
||||
if r.Body == "" {
|
||||
return fmt.Errorf("body is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToNotification converts the request to a domain notification
|
||||
func (r *SendNotificationRequest) ToNotification() *domain.Notification {
|
||||
maxRetries := r.MaxRetries
|
||||
if maxRetries == 0 {
|
||||
maxRetries = 3 // Default
|
||||
}
|
||||
|
||||
return &domain.Notification{
|
||||
ID: uuid.New().String(),
|
||||
Type: domain.NotificationType(r.Type),
|
||||
Priority: domain.Priority(r.Priority),
|
||||
Status: domain.StatusPending,
|
||||
Subject: r.Subject,
|
||||
Body: r.Body,
|
||||
Recipients: r.Recipients,
|
||||
Metadata: r.Metadata,
|
||||
CreatedAt: time.Now(),
|
||||
ScheduledFor: r.ScheduledFor,
|
||||
MaxRetries: maxRetries,
|
||||
RetryCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// SendNotificationResponse is the REST API response for sending a notification
|
||||
type SendNotificationResponse struct {
|
||||
Result NotificationResult `json:"result"`
|
||||
}
|
||||
|
||||
// SendBatchNotificationsRequest is the REST API request for sending multiple notifications
|
||||
type SendBatchNotificationsRequest struct {
|
||||
Notifications []SendNotificationRequest `json:"notifications"`
|
||||
}
|
||||
|
||||
// SendBatchNotificationsResponse is the REST API response for sending multiple notifications
|
||||
type SendBatchNotificationsResponse struct {
|
||||
Results []NotificationResult `json:"results"`
|
||||
}
|
||||
|
||||
// Notification represents a notification in the REST API
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationFromDomain converts a domain notification to API format
|
||||
func NotificationFromDomain(n *domain.Notification) Notification {
|
||||
return Notification{
|
||||
ID: n.ID,
|
||||
Type: string(n.Type),
|
||||
Priority: int(n.Priority),
|
||||
Status: string(n.Status),
|
||||
Subject: n.Subject,
|
||||
Body: n.Body,
|
||||
Recipients: n.Recipients,
|
||||
Metadata: n.Metadata,
|
||||
CreatedAt: n.CreatedAt,
|
||||
ScheduledFor: n.ScheduledFor,
|
||||
SentAt: n.SentAt,
|
||||
RetryCount: n.RetryCount,
|
||||
MaxRetries: n.MaxRetries,
|
||||
LastError: n.LastError,
|
||||
}
|
||||
}
|
||||
|
||||
// NotificationResult represents the result of a notification operation
|
||||
type NotificationResult struct {
|
||||
NotificationID string `json:"notification_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
ProviderResponse map[string]interface{} `json:"provider_response,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationResultFromDomain converts a domain result to API format
|
||||
func NotificationResultFromDomain(r *domain.NotificationResult) NotificationResult {
|
||||
return NotificationResult{
|
||||
NotificationID: r.NotificationID,
|
||||
Success: r.Success,
|
||||
Message: r.Message,
|
||||
Error: r.Error,
|
||||
SentAt: r.SentAt,
|
||||
ProviderResponse: r.ProviderResponse,
|
||||
}
|
||||
}
|
||||
|
||||
// ListNotificationsResponse is the REST API response for listing notifications
|
||||
type ListNotificationsResponse struct {
|
||||
Notifications []Notification `json:"notifications"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// RetryNotificationResponse is the REST API response for retrying a notification
|
||||
type RetryNotificationResponse struct {
|
||||
Result NotificationResult `json:"result"`
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/api/rest"
|
||||
"github.com/igodwin/notifier/internal/config"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/notifier"
|
||||
"github.com/igodwin/notifier/internal/queue"
|
||||
"github.com/igodwin/notifier/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load config, using defaults: %v", err)
|
||||
cfg = getDefaultConfig()
|
||||
}
|
||||
|
||||
log.Printf("Starting Notifier REST Server on port %d", cfg.Server.RESTPort)
|
||||
|
||||
// Create context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Initialize queue
|
||||
var q domain.Queue
|
||||
if cfg.Queue.Type == "local" {
|
||||
q, err = queue.NewLocalQueue(cfg.Queue.Local)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create queue: %v", err)
|
||||
}
|
||||
log.Println("Using local queue")
|
||||
} else {
|
||||
log.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type)
|
||||
}
|
||||
|
||||
// Initialize notifier factory
|
||||
factory := notifier.NewFactory()
|
||||
|
||||
// Register notifiers based on configuration
|
||||
if cfg.Notifiers.Stdout {
|
||||
stdoutNotifier := notifier.NewStdoutNotifier()
|
||||
if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil {
|
||||
log.Fatalf("Failed to register stdout notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered stdout notifier")
|
||||
}
|
||||
|
||||
if cfg.Notifiers.SMTP != nil {
|
||||
smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create SMTP notifier: %v", err)
|
||||
} else {
|
||||
if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil {
|
||||
log.Fatalf("Failed to register SMTP notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered SMTP notifier")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Notifiers.Slack != nil {
|
||||
slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create Slack notifier: %v", err)
|
||||
} else {
|
||||
if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil {
|
||||
log.Fatalf("Failed to register Slack notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered Slack notifier")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Notifiers.Ntfy != nil {
|
||||
ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create Ntfy notifier: %v", err)
|
||||
} else {
|
||||
if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil {
|
||||
log.Fatalf("Failed to register Ntfy notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered Ntfy notifier")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any notifiers are registered
|
||||
if len(factory.SupportedTypes()) == 0 {
|
||||
log.Fatal("No notifiers configured. Please enable at least one notifier in config.yaml")
|
||||
}
|
||||
|
||||
log.Printf("Supported notification types: %v", factory.SupportedTypes())
|
||||
|
||||
// Create notification service
|
||||
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount)
|
||||
|
||||
// Start workers
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
log.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
log.Printf("Started %d worker(s)", cfg.Queue.WorkerCount)
|
||||
|
||||
// Create REST router
|
||||
router := rest.NewRouter(svc)
|
||||
|
||||
// Create HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
log.Printf("REST server listening on %s", addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Graceful shutdown
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("Error during server shutdown: %v", err)
|
||||
}
|
||||
|
||||
// Stop service
|
||||
if err := svc.Stop(); err != nil {
|
||||
log.Printf("Error stopping service: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
|
||||
// getDefaultConfig returns a minimal default configuration
|
||||
func getDefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
RESTPort: 8080,
|
||||
Host: "0.0.0.0",
|
||||
},
|
||||
Queue: domain.QueueConfig{
|
||||
Type: "local",
|
||||
WorkerCount: 5,
|
||||
RetryAttempts: 3,
|
||||
Local: &domain.LocalQueueConfig{
|
||||
BufferSize: 1000,
|
||||
},
|
||||
},
|
||||
Notifiers: config.NotifiersConfig{
|
||||
Stdout: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/api/rest"
|
||||
"github.com/igodwin/notifier/internal/config"
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/notifier"
|
||||
"github.com/igodwin/notifier/internal/queue"
|
||||
"github.com/igodwin/notifier/internal/service"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load config, using defaults: %v", err)
|
||||
cfg = getDefaultConfig()
|
||||
}
|
||||
|
||||
log.Printf("Starting Notifier Service in mode: %s", cfg.Server.Mode)
|
||||
|
||||
// Create context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Initialize queue
|
||||
var q domain.Queue
|
||||
if cfg.Queue.Type == "local" {
|
||||
q, err = queue.NewLocalQueue(cfg.Queue.Local)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create queue: %v", err)
|
||||
}
|
||||
log.Println("Using local queue")
|
||||
} else {
|
||||
log.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type)
|
||||
}
|
||||
|
||||
// Initialize notifier factory and register notifiers
|
||||
factory := notifier.NewFactory()
|
||||
registerNotifiers(cfg, factory)
|
||||
|
||||
// Check if any notifiers are registered
|
||||
if len(factory.SupportedTypes()) == 0 {
|
||||
log.Fatal("No notifiers configured. Please enable at least one notifier in config.yaml")
|
||||
}
|
||||
|
||||
log.Printf("Supported notification types: %v", factory.SupportedTypes())
|
||||
|
||||
// Create notification service
|
||||
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount)
|
||||
|
||||
// Start workers
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
log.Fatalf("Failed to start service: %v", err)
|
||||
}
|
||||
log.Printf("Started %d worker(s)", cfg.Queue.WorkerCount)
|
||||
|
||||
// Wait group for both servers
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start gRPC server if enabled
|
||||
var grpcServer *grpc.Server
|
||||
if cfg.Server.Mode == "both" || cfg.Server.Mode == "grpc" {
|
||||
wg.Add(1)
|
||||
grpcServer = startGRPCServer(ctx, &wg, cfg, svc)
|
||||
}
|
||||
|
||||
// Start REST server if enabled
|
||||
var restServer *http.Server
|
||||
if cfg.Server.Mode == "both" || cfg.Server.Mode == "rest" {
|
||||
wg.Add(1)
|
||||
restServer = startRESTServer(ctx, &wg, cfg, svc)
|
||||
}
|
||||
|
||||
// Wait for interrupt signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
log.Println("Shutting down servers...")
|
||||
|
||||
// Graceful shutdown
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
// Stop REST server
|
||||
if restServer != nil {
|
||||
if err := restServer.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("Error during REST server shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gRPC server
|
||||
if grpcServer != nil {
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
|
||||
// Wait for servers to stop
|
||||
wg.Wait()
|
||||
|
||||
// Stop service
|
||||
if err := svc.Stop(); err != nil {
|
||||
log.Printf("Error stopping service: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Servers stopped")
|
||||
}
|
||||
|
||||
func registerNotifiers(cfg *config.Config, factory *notifier.Factory) {
|
||||
if cfg.Notifiers.Stdout {
|
||||
stdoutNotifier := notifier.NewStdoutNotifier()
|
||||
if err := factory.RegisterNotifier(domain.TypeStdout, stdoutNotifier); err != nil {
|
||||
log.Fatalf("Failed to register stdout notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered stdout notifier")
|
||||
}
|
||||
|
||||
if cfg.Notifiers.SMTP != nil {
|
||||
smtpNotifier, err := notifier.NewSMTPNotifier(cfg.Notifiers.SMTP)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create SMTP notifier: %v", err)
|
||||
} else {
|
||||
if err := factory.RegisterNotifier(domain.TypeEmail, smtpNotifier); err != nil {
|
||||
log.Fatalf("Failed to register SMTP notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered SMTP notifier")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Notifiers.Slack != nil {
|
||||
slackNotifier, err := notifier.NewSlackNotifier(cfg.Notifiers.Slack)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create Slack notifier: %v", err)
|
||||
} else {
|
||||
if err := factory.RegisterNotifier(domain.TypeSlack, slackNotifier); err != nil {
|
||||
log.Fatalf("Failed to register Slack notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered Slack notifier")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Notifiers.Ntfy != nil {
|
||||
ntfyNotifier, err := notifier.NewNtfyNotifier(cfg.Notifiers.Ntfy)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create Ntfy notifier: %v", err)
|
||||
} else {
|
||||
if err := factory.RegisterNotifier(domain.TypeNtfy, ntfyNotifier); err != nil {
|
||||
log.Fatalf("Failed to register Ntfy notifier: %v", err)
|
||||
}
|
||||
log.Println("Registered Ntfy notifier")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startGRPCServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService) *grpc.Server {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.GRPCPort)
|
||||
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen on %s: %v", addr, err)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
|
||||
// TODO: Register gRPC service implementation when protobuf is generated
|
||||
// pb.RegisterNotifierServiceServer(grpcServer, grpcHandler)
|
||||
|
||||
// Enable reflection for tools like grpcurl
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("Failed to serve gRPC: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return grpcServer
|
||||
}
|
||||
|
||||
func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService) *http.Server {
|
||||
router := rest.NewRouter(svc)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
log.Printf("REST server listening on %s", addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start REST server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func getDefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
GRPCPort: 50051,
|
||||
RESTPort: 8080,
|
||||
Host: "0.0.0.0",
|
||||
Mode: "both",
|
||||
},
|
||||
Queue: domain.QueueConfig{
|
||||
Type: "local",
|
||||
WorkerCount: 5,
|
||||
RetryAttempts: 3,
|
||||
Local: &domain.LocalQueueConfig{
|
||||
BufferSize: 1000,
|
||||
},
|
||||
},
|
||||
Notifiers: config.NotifiersConfig{
|
||||
Stdout: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Notifier Service Configuration
|
||||
|
||||
server:
|
||||
grpc_port: 50051
|
||||
rest_port: 8080
|
||||
host: "0.0.0.0"
|
||||
mode: "both" # Options: both, grpc, rest
|
||||
|
||||
queue:
|
||||
type: "local" # Options: local, kafka
|
||||
max_size: 10000
|
||||
worker_count: 10
|
||||
retry_attempts: 3
|
||||
retry_backoff: "exponential" # Options: exponential, linear, fixed
|
||||
|
||||
# Local queue configuration
|
||||
local:
|
||||
buffer_size: 1000
|
||||
persist_to_disk: false
|
||||
persist_path: "/var/lib/notifier/queue.json"
|
||||
|
||||
# Kafka queue configuration (when type: kafka)
|
||||
# kafka:
|
||||
# brokers:
|
||||
# - "localhost:9092"
|
||||
# topic: "notifications"
|
||||
# consumer_group: "notifier-service"
|
||||
# partition_count: 10
|
||||
# replication_factor: 3
|
||||
# enable_idempotence: true
|
||||
# compression_type: "snappy"
|
||||
|
||||
notifiers:
|
||||
# Enable stdout notifier (useful for development/debugging)
|
||||
stdout: true
|
||||
|
||||
# SMTP email configuration
|
||||
smtp:
|
||||
host: "smtp.gmail.com"
|
||||
port: 587
|
||||
username: "your-email@gmail.com"
|
||||
password: "your-app-password"
|
||||
from: "notifications@yourservice.com"
|
||||
use_tls: true
|
||||
|
||||
# Slack configuration
|
||||
slack:
|
||||
webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
|
||||
# token: "xoxb-your-bot-token" # Alternative to webhook
|
||||
# channel: "#notifications" # Default channel
|
||||
username: "Notifier Bot"
|
||||
icon_emoji: ":bell:"
|
||||
# Channel-specific webhooks
|
||||
# webhooks:
|
||||
# "#alerts": "https://hooks.slack.com/services/ALERTS/WEBHOOK"
|
||||
# "#monitoring": "https://hooks.slack.com/services/MONITORING/WEBHOOK"
|
||||
|
||||
# Ntfy configuration
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
|
||||
# Token-based authentication (recommended for ntfy.sh)
|
||||
# Supports both access tokens (tk_...) and publish tokens
|
||||
# token: "tk_your_access_token"
|
||||
|
||||
# Basic authentication (alternative to token)
|
||||
# username: "your-username"
|
||||
# password: "your-password"
|
||||
|
||||
# Default topic if recipient not specified
|
||||
# default_topic: "default-notifications"
|
||||
|
||||
# Skip TLS verification (for self-hosted servers with self-signed certs)
|
||||
# insecure_skip_verify: false
|
||||
|
||||
logging:
|
||||
level: "info" # Options: debug, info, warn, error
|
||||
format: "json" # Options: json, text
|
||||
output_path: "stdout" # Options: stdout, stderr, or file path
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
path: "/metrics"
|
||||
prometheus_enabled: true
|
||||
|
||||
health_check:
|
||||
enabled: true
|
||||
port: 8081
|
||||
path: "/health"
|
||||
interval: 30 # seconds
|
||||
@@ -0,0 +1,95 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
notifier:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: notifier
|
||||
ports:
|
||||
- "8080:8080" # REST API
|
||||
- "50051:50051" # gRPC
|
||||
- "9090:9090" # Metrics
|
||||
- "8081:8081" # Health check
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- notifier-data:/var/lib/notifier
|
||||
environment:
|
||||
- NOTIFIER_SERVER_MODE=both
|
||||
- NOTIFIER_LOGGING_LEVEL=info
|
||||
- NOTIFIER_LOGGING_FORMAT=json
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- notifier-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Optional: Kafka for distributed queue
|
||||
# kafka:
|
||||
# image: confluentinc/cp-kafka:latest
|
||||
# container_name: kafka
|
||||
# ports:
|
||||
# - "9092:9092"
|
||||
# environment:
|
||||
# KAFKA_BROKER_ID: 1
|
||||
# KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||
# KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
|
||||
# KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
# depends_on:
|
||||
# - zookeeper
|
||||
# networks:
|
||||
# - notifier-net
|
||||
|
||||
# zookeeper:
|
||||
# image: confluentinc/cp-zookeeper:latest
|
||||
# container_name: zookeeper
|
||||
# ports:
|
||||
# - "2181:2181"
|
||||
# environment:
|
||||
# ZOOKEEPER_CLIENT_PORT: 2181
|
||||
# ZOOKEEPER_TICK_TIME: 2000
|
||||
# networks:
|
||||
# - notifier-net
|
||||
|
||||
# Optional: Prometheus for metrics collection
|
||||
# prometheus:
|
||||
# image: prom/prometheus:latest
|
||||
# container_name: prometheus
|
||||
# ports:
|
||||
# - "9091:9090"
|
||||
# volumes:
|
||||
# - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
# - prometheus-data:/prometheus
|
||||
# command:
|
||||
# - '--config.file=/etc/prometheus/prometheus.yml'
|
||||
# - '--storage.tsdb.path=/prometheus'
|
||||
# networks:
|
||||
# - notifier-net
|
||||
|
||||
# Optional: Grafana for visualization
|
||||
# grafana:
|
||||
# image: grafana/grafana:latest
|
||||
# container_name: grafana
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
# environment:
|
||||
# - GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
# volumes:
|
||||
# - grafana-data:/var/lib/grafana
|
||||
# depends_on:
|
||||
# - prometheus
|
||||
# networks:
|
||||
# - notifier-net
|
||||
|
||||
networks:
|
||||
notifier-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
notifier-data:
|
||||
# prometheus-data:
|
||||
# grafana-data:
|
||||
@@ -0,0 +1,479 @@
|
||||
# Ntfy Integration Guide
|
||||
|
||||
This guide explains how to use ntfy.sh notifications with the Notifier service.
|
||||
|
||||
## What is Ntfy?
|
||||
|
||||
[ntfy](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can send notifications to your phone, desktop, or any device that subscribes to your topics. It's perfect for:
|
||||
|
||||
- Push notifications to mobile devices
|
||||
- Desktop notifications
|
||||
- Server alerts and monitoring
|
||||
- CI/CD pipeline notifications
|
||||
- IoT device notifications
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### 1. Token-Based Authentication (Recommended)
|
||||
|
||||
Token authentication is the preferred method for ntfy.sh.
|
||||
|
||||
#### Access Tokens (for authenticated topics)
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
token: "tk_your_access_token"
|
||||
```
|
||||
|
||||
Get an access token:
|
||||
1. Go to https://ntfy.sh/account
|
||||
2. Create an account or log in
|
||||
3. Go to "Access Tokens"
|
||||
4. Create a new token with appropriate permissions
|
||||
5. Copy the token (starts with `tk_`)
|
||||
|
||||
#### Publish Tokens (for specific topics)
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
token: "your_publish_token"
|
||||
```
|
||||
|
||||
Create a publish token:
|
||||
1. Create a topic with reserved access
|
||||
2. Generate a publish token for that topic
|
||||
3. Use the token in your configuration
|
||||
|
||||
### 2. Basic Authentication
|
||||
|
||||
Alternative to token auth:
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
username: "your-username"
|
||||
password: "your-password"
|
||||
```
|
||||
|
||||
### 3. No Authentication (Public Topics)
|
||||
|
||||
For public topics on ntfy.sh:
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.sh"
|
||||
# No token, username, or password needed
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Full Configuration Example
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
# Server URL (default: https://ntfy.sh)
|
||||
server_url: "https://ntfy.sh"
|
||||
|
||||
# Authentication (choose one method)
|
||||
token: "tk_your_token" # Token auth (recommended)
|
||||
# username: "user" # Or basic auth
|
||||
# password: "pass"
|
||||
|
||||
# Optional: default topic if not specified in notification
|
||||
default_topic: "my-default-topic"
|
||||
|
||||
# Optional: skip TLS verification (for self-hosted with self-signed certs)
|
||||
insecure_skip_verify: false
|
||||
```
|
||||
|
||||
### Self-Hosted Ntfy Server
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
ntfy:
|
||||
server_url: "https://ntfy.yourcompany.com"
|
||||
token: "your_custom_token"
|
||||
# For self-signed certificates
|
||||
insecure_skip_verify: true
|
||||
```
|
||||
|
||||
## Sending Notifications
|
||||
|
||||
### Basic Notification
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Hello from Notifier!",
|
||||
"body": "This is a test notification",
|
||||
"recipients": ["my-topic"]
|
||||
}'
|
||||
```
|
||||
|
||||
### With Priority
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"priority": 4,
|
||||
"subject": "CRITICAL Alert",
|
||||
"body": "Something important happened!",
|
||||
"recipients": ["alerts"]
|
||||
}'
|
||||
```
|
||||
|
||||
Priority mapping:
|
||||
- `0` (Low) → ntfy priority 2
|
||||
- `1` (Normal) → ntfy priority 3 (default)
|
||||
- `2` (High) → ntfy priority 4
|
||||
- `3` (Critical) → ntfy priority 5 (max)
|
||||
|
||||
### With Tags (Emojis)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Deployment Complete",
|
||||
"body": "Application deployed successfully",
|
||||
"recipients": ["deployments"],
|
||||
"metadata": {
|
||||
"tags": ["rocket", "tada", "white_check_mark"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Common tags:
|
||||
- `warning`, `rotating_light`, `skull` - Alerts
|
||||
- `tada`, `rocket`, `sparkles` - Success
|
||||
- `x`, `no_entry`, `stop_sign` - Errors
|
||||
- `information_source`, `eyes` - Info
|
||||
|
||||
### With Click Action
|
||||
|
||||
Make the notification clickable:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "New Pull Request",
|
||||
"body": "PR #123 needs review",
|
||||
"recipients": ["github-notifications"],
|
||||
"metadata": {
|
||||
"click": "https://github.com/your-org/your-repo/pull/123",
|
||||
"tags": ["github"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### With Attachment
|
||||
|
||||
Attach an image or file:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Server Stats",
|
||||
"body": "Current server metrics",
|
||||
"recipients": ["monitoring"],
|
||||
"metadata": {
|
||||
"attach": "https://example.com/metrics.png",
|
||||
"tags": ["chart_with_upwards_trend"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### With Custom Icon
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Custom Notification",
|
||||
"body": "With a custom icon",
|
||||
"recipients": ["custom-alerts"],
|
||||
"metadata": {
|
||||
"icon": "https://example.com/logo.png"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### With Delayed Delivery
|
||||
|
||||
Schedule notification for later:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Reminder",
|
||||
"body": "Meeting in 30 minutes",
|
||||
"recipients": ["reminders"],
|
||||
"metadata": {
|
||||
"delay": "30m",
|
||||
"tags": ["alarm_clock"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Delay formats:
|
||||
- `30s` - 30 seconds
|
||||
- `5m` - 5 minutes
|
||||
- `2h` - 2 hours
|
||||
- `tomorrow 10am` - Tomorrow at 10 AM
|
||||
|
||||
### With Action Buttons
|
||||
|
||||
Add interactive buttons:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Deploy to Production?",
|
||||
"body": "Version 2.0 is ready",
|
||||
"recipients": ["deployments"],
|
||||
"metadata": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "view",
|
||||
"label": "View Release",
|
||||
"url": "https://github.com/your-org/your-repo/releases/v2.0",
|
||||
"clear": true
|
||||
},
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Deploy",
|
||||
"url": "https://api.yourcompany.com/deploy",
|
||||
"body": "{\"version\": \"2.0\"}",
|
||||
"clear": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Action types:
|
||||
- `view` - Open a URL
|
||||
- `http` - Send HTTP request
|
||||
- `broadcast` - Android broadcast intent
|
||||
|
||||
### With Email Forwarding
|
||||
|
||||
Forward to email (requires ntfy.sh tier 2+):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "Important Alert",
|
||||
"body": "This will also be sent via email",
|
||||
"recipients": ["alerts"],
|
||||
"metadata": {
|
||||
"email": "admin@example.com",
|
||||
"tags": ["email"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Multiple Topics
|
||||
|
||||
Send to multiple topics:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/notifications \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "ntfy",
|
||||
"subject": "System Update",
|
||||
"body": "System will restart in 5 minutes",
|
||||
"recipients": ["admins", "monitoring", "alerts"],
|
||||
"metadata": {
|
||||
"tags": ["warning"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Mobile App Setup
|
||||
|
||||
### iOS
|
||||
1. Download ntfy from the App Store
|
||||
2. Add a topic subscription
|
||||
3. Use the same topic name in your notifications
|
||||
|
||||
### Android
|
||||
1. Download ntfy from Google Play or F-Droid
|
||||
2. Add a topic subscription
|
||||
3. Configure notification settings
|
||||
4. Use the same topic name in your notifications
|
||||
|
||||
### Desktop (Linux/macOS/Windows)
|
||||
```bash
|
||||
# Install ntfy CLI
|
||||
curl -sSL https://ntfy.sh/install.sh | sh
|
||||
|
||||
# Subscribe to topics
|
||||
ntfy subscribe mytopic
|
||||
```
|
||||
|
||||
## Topic Naming Best Practices
|
||||
|
||||
### Public Topics
|
||||
- Use unique, hard-to-guess names
|
||||
- Consider including random strings: `myapp-alerts-x7k9p2`
|
||||
- Anyone who knows the name can subscribe
|
||||
|
||||
### Private Topics (Recommended)
|
||||
- Requires authentication
|
||||
- Create reserved topics on ntfy.sh
|
||||
- Use access control lists (ACLs)
|
||||
|
||||
### Topic Organization
|
||||
```yaml
|
||||
# Example topic structure
|
||||
- myapp-prod-alerts # Production alerts
|
||||
- myapp-prod-info # Production info
|
||||
- myapp-staging-alerts # Staging alerts
|
||||
- myapp-ci-cd # CI/CD notifications
|
||||
- myapp-monitoring # Monitoring metrics
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Override configuration with environment variables:
|
||||
|
||||
```bash
|
||||
export NOTIFIER_NOTIFIERS_NTFY_SERVER_URL=https://ntfy.yourcompany.com
|
||||
export NOTIFIER_NOTIFIERS_NTFY_TOKEN=tk_your_token
|
||||
export NOTIFIER_NOTIFIERS_NTFY_DEFAULT_TOPIC=default-topic
|
||||
export NOTIFIER_NOTIFIERS_NTFY_INSECURE_SKIP_VERIFY=false
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
- **Never commit tokens to version control**
|
||||
- Store tokens in Kubernetes Secrets or environment variables
|
||||
- Rotate tokens regularly
|
||||
- Use publish tokens with limited scope when possible
|
||||
|
||||
### Topic Security
|
||||
- Use reserved/private topics for sensitive data
|
||||
- Don't include secrets in notification bodies
|
||||
- Consider encryption for highly sensitive data
|
||||
- Use unique topic names to prevent enumeration
|
||||
|
||||
### Self-Hosted Servers
|
||||
- Use TLS with valid certificates
|
||||
- Enable authentication
|
||||
- Configure rate limiting
|
||||
- Monitor access logs
|
||||
- Keep ntfy server updated
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
### Using Secrets
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: notifier-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
ntfy-token: "tk_your_access_token"
|
||||
```
|
||||
|
||||
### Deployment Configuration
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: NOTIFIER_NOTIFIERS_NTFY_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: notifier-secrets
|
||||
key: ntfy-token
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
### ntfy.sh Free Tier
|
||||
- 250 messages/day per visitor
|
||||
- Unlimited topics
|
||||
- Message retention: 12 hours
|
||||
|
||||
### ntfy.sh Tier 1 ($5/month)
|
||||
- 500 messages/day
|
||||
- Message retention: 1 day
|
||||
- Attachment & email support
|
||||
|
||||
### ntfy.sh Tier 2 ($10/month)
|
||||
- 1000 messages/day
|
||||
- Message retention: 7 days
|
||||
- Higher attachment limits
|
||||
|
||||
### Self-Hosted
|
||||
- Configure your own limits
|
||||
- Full control over retention
|
||||
- No external dependencies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
```
|
||||
Error: ntfy server returned status: 401
|
||||
```
|
||||
**Solution**: Verify token is correct and has necessary permissions
|
||||
|
||||
### Topic Not Found
|
||||
```
|
||||
Error: ntfy server returned status: 404
|
||||
```
|
||||
**Solution**: Check topic name spelling, ensure topic exists if using reserved topics
|
||||
|
||||
### TLS Errors (Self-Hosted)
|
||||
```
|
||||
Error: x509: certificate signed by unknown authority
|
||||
```
|
||||
**Solution**: Either fix certificate or set `insecure_skip_verify: true` (not recommended for production)
|
||||
|
||||
### Rate Limit Exceeded
|
||||
```
|
||||
Error: ntfy server returned status: 429
|
||||
```
|
||||
**Solution**: Reduce message frequency or upgrade ntfy.sh tier
|
||||
|
||||
### Connection Timeout
|
||||
```
|
||||
Error: context deadline exceeded
|
||||
```
|
||||
**Solution**: Check network connectivity, firewall rules, or server URL
|
||||
|
||||
## Examples
|
||||
|
||||
See [QUICKSTART.md](../QUICKSTART.md) for more examples of using ntfy with the Notifier service.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Ntfy Documentation](https://docs.ntfy.sh)
|
||||
- [Ntfy.sh Public Instance](https://ntfy.sh)
|
||||
- [Self-Hosting Guide](https://docs.ntfy.sh/install/)
|
||||
- [API Reference](https://docs.ntfy.sh/publish/)
|
||||
@@ -1,3 +1,36 @@
|
||||
module github.com/igodwin/notifier
|
||||
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
google.golang.org/grpc v1.62.1
|
||||
google.golang.org/protobuf v1.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
|
||||
google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
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=
|
||||
@@ -0,0 +1,211 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/igodwin/notifier/internal/notifier"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Queue domain.QueueConfig `mapstructure:"queue"`
|
||||
Notifiers NotifiersConfig `mapstructure:"notifiers"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Metrics MetricsConfig `mapstructure:"metrics"`
|
||||
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
|
||||
}
|
||||
|
||||
// ServerConfig contains server configuration
|
||||
type ServerConfig struct {
|
||||
GRPCPort int `mapstructure:"grpc_port"`
|
||||
RESTPort int `mapstructure:"rest_port"`
|
||||
Host string `mapstructure:"host"`
|
||||
Mode string `mapstructure:"mode"` // "both", "grpc", "rest"
|
||||
}
|
||||
|
||||
// NotifiersConfig contains configuration for all notifier types
|
||||
type NotifiersConfig struct {
|
||||
SMTP *notifier.SMTPConfig `mapstructure:"smtp"`
|
||||
Slack *notifier.SlackConfig `mapstructure:"slack"`
|
||||
Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"`
|
||||
Stdout bool `mapstructure:"stdout"` // Enable stdout notifier
|
||||
}
|
||||
|
||||
// LoggingConfig contains logging configuration
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"` // debug, info, warn, error
|
||||
Format string `mapstructure:"format"` // json, text
|
||||
OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path
|
||||
}
|
||||
|
||||
// MetricsConfig contains metrics/observability configuration
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Port int `mapstructure:"port"`
|
||||
Path string `mapstructure:"path"`
|
||||
PrometheusEnabled bool `mapstructure:"prometheus_enabled"`
|
||||
}
|
||||
|
||||
// HealthCheckConfig contains health check configuration
|
||||
type HealthCheckConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Port int `mapstructure:"port"`
|
||||
Path string `mapstructure:"path"`
|
||||
Interval int `mapstructure:"interval"` // seconds
|
||||
}
|
||||
|
||||
// Load loads configuration from file and environment variables
|
||||
func Load(configPath string) (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set default values
|
||||
setDefaults(v)
|
||||
|
||||
// Configure viper
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if configPath != "" {
|
||||
v.AddConfigPath(configPath)
|
||||
}
|
||||
|
||||
// Also look in common locations
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("./config")
|
||||
v.AddConfigPath("/etc/notifier")
|
||||
v.AddConfigPath("$HOME/.notifier")
|
||||
|
||||
// Environment variable support
|
||||
v.SetEnvPrefix("NOTIFIER")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Read config file
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
// Config file is optional if environment variables are set
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// setDefaults sets default configuration values
|
||||
func setDefaults(v *viper.Viper) {
|
||||
// Server defaults
|
||||
v.SetDefault("server.grpc_port", 50051)
|
||||
v.SetDefault("server.rest_port", 8080)
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.mode", "both")
|
||||
|
||||
// Queue defaults
|
||||
v.SetDefault("queue.type", "local")
|
||||
v.SetDefault("queue.max_size", 10000)
|
||||
v.SetDefault("queue.worker_count", 10)
|
||||
v.SetDefault("queue.retry_attempts", 3)
|
||||
v.SetDefault("queue.retry_backoff", "exponential")
|
||||
|
||||
// Local queue defaults
|
||||
v.SetDefault("queue.local.buffer_size", 1000)
|
||||
v.SetDefault("queue.local.persist_to_disk", false)
|
||||
|
||||
// Logging defaults
|
||||
v.SetDefault("logging.level", "info")
|
||||
v.SetDefault("logging.format", "json")
|
||||
v.SetDefault("logging.output_path", "stdout")
|
||||
|
||||
// Metrics defaults
|
||||
v.SetDefault("metrics.enabled", true)
|
||||
v.SetDefault("metrics.port", 9090)
|
||||
v.SetDefault("metrics.path", "/metrics")
|
||||
v.SetDefault("metrics.prometheus_enabled", true)
|
||||
|
||||
// Health check defaults
|
||||
v.SetDefault("health_check.enabled", true)
|
||||
v.SetDefault("health_check.port", 8081)
|
||||
v.SetDefault("health_check.path", "/health")
|
||||
v.SetDefault("health_check.interval", 30)
|
||||
|
||||
// Notifier defaults
|
||||
v.SetDefault("notifiers.stdout", true)
|
||||
v.SetDefault("notifiers.smtp.port", 587)
|
||||
v.SetDefault("notifiers.smtp.use_tls", true)
|
||||
v.SetDefault("notifiers.ntfy.server_url", "https://ntfy.sh")
|
||||
}
|
||||
|
||||
// Validate validates the configuration
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server config
|
||||
if c.Server.GRPCPort < 1 || c.Server.GRPCPort > 65535 {
|
||||
return fmt.Errorf("invalid gRPC port: %d", c.Server.GRPCPort)
|
||||
}
|
||||
|
||||
if c.Server.RESTPort < 1 || c.Server.RESTPort > 65535 {
|
||||
return fmt.Errorf("invalid REST port: %d", c.Server.RESTPort)
|
||||
}
|
||||
|
||||
validModes := map[string]bool{"both": true, "grpc": true, "rest": true}
|
||||
if !validModes[c.Server.Mode] {
|
||||
return fmt.Errorf("invalid server mode: %s (must be both, grpc, or rest)", c.Server.Mode)
|
||||
}
|
||||
|
||||
// Validate queue config
|
||||
validQueueTypes := map[string]bool{"local": true, "kafka": true}
|
||||
if !validQueueTypes[c.Queue.Type] {
|
||||
return fmt.Errorf("invalid queue type: %s (must be local or kafka)", c.Queue.Type)
|
||||
}
|
||||
|
||||
if c.Queue.Type == "kafka" && c.Queue.Kafka == nil {
|
||||
return fmt.Errorf("Kafka queue type selected but no Kafka configuration provided")
|
||||
}
|
||||
|
||||
// Validate at least one notifier is configured
|
||||
if !c.HasAnyNotifier() {
|
||||
return fmt.Errorf("at least one notifier must be configured")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasAnyNotifier checks if at least one notifier is configured
|
||||
func (c *Config) HasAnyNotifier() bool {
|
||||
return c.Notifiers.Stdout ||
|
||||
c.Notifiers.SMTP != nil ||
|
||||
c.Notifiers.Slack != nil ||
|
||||
c.Notifiers.Ntfy != nil
|
||||
}
|
||||
|
||||
// GetEnabledNotifiers returns a list of enabled notifier types
|
||||
func (c *Config) GetEnabledNotifiers() []domain.NotificationType {
|
||||
var enabled []domain.NotificationType
|
||||
|
||||
if c.Notifiers.Stdout {
|
||||
enabled = append(enabled, domain.TypeStdout)
|
||||
}
|
||||
if c.Notifiers.SMTP != nil {
|
||||
enabled = append(enabled, domain.TypeEmail)
|
||||
}
|
||||
if c.Notifiers.Slack != nil {
|
||||
enabled = append(enabled, domain.TypeSlack)
|
||||
}
|
||||
if c.Notifiers.Ntfy != nil {
|
||||
enabled = append(enabled, domain.TypeNtfy)
|
||||
}
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Priority defines the urgency level of a notification
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
PriorityLow Priority = iota
|
||||
PriorityNormal
|
||||
PriorityHigh
|
||||
PriorityCritical
|
||||
)
|
||||
|
||||
// NotificationType defines the channel through which to send the notification
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
TypeEmail NotificationType = "email"
|
||||
TypeSlack NotificationType = "slack"
|
||||
TypeNtfy NotificationType = "ntfy"
|
||||
TypeStdout NotificationType = "stdout"
|
||||
)
|
||||
|
||||
// NotificationStatus represents the current state of a notification
|
||||
type NotificationStatus string
|
||||
|
||||
const (
|
||||
StatusPending NotificationStatus = "pending"
|
||||
StatusQueued NotificationStatus = "queued"
|
||||
StatusProcessing NotificationStatus = "processing"
|
||||
StatusSent NotificationStatus = "sent"
|
||||
StatusFailed NotificationStatus = "failed"
|
||||
StatusRetrying NotificationStatus = "retrying"
|
||||
)
|
||||
|
||||
// Notification represents a notification message with metadata
|
||||
type Notification struct {
|
||||
// ID is a unique identifier for the notification
|
||||
ID string `json:"id"`
|
||||
|
||||
// Type specifies which notifier should handle this notification
|
||||
Type NotificationType `json:"type"`
|
||||
|
||||
// Priority determines urgency and retry behavior
|
||||
Priority Priority `json:"priority"`
|
||||
|
||||
// Status tracks the current state of the notification
|
||||
Status NotificationStatus `json:"status"`
|
||||
|
||||
// Subject is the notification subject/title (used for email, slack, ntfy)
|
||||
Subject string `json:"subject"`
|
||||
|
||||
// Body is the main content of the notification
|
||||
Body string `json:"body"`
|
||||
|
||||
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
|
||||
Recipients []string `json:"recipients"`
|
||||
|
||||
// Metadata contains additional provider-specific data
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// CreatedAt is when the notification was created
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ScheduledFor allows delayed sending (optional)
|
||||
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||
|
||||
// SentAt is when the notification was successfully sent
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
|
||||
// RetryCount tracks how many times sending has been attempted
|
||||
RetryCount int `json:"retry_count"`
|
||||
|
||||
// MaxRetries defines the maximum retry attempts
|
||||
MaxRetries int `json:"max_retries"`
|
||||
|
||||
// LastError stores the most recent error message if failed
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationResult represents the outcome of sending a notification
|
||||
type NotificationResult struct {
|
||||
// NotificationID references the original notification
|
||||
NotificationID string `json:"notification_id"`
|
||||
|
||||
// Success indicates if the notification was sent successfully
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Message provides additional context about the result
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// Error contains error details if the notification failed
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// SentAt is when the notification was sent
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
|
||||
// ProviderResponse contains raw response data from the notification provider
|
||||
ProviderResponse map[string]interface{} `json:"provider_response,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationFilter is used for querying notifications
|
||||
type NotificationFilter struct {
|
||||
IDs []string `json:"ids,omitempty"`
|
||||
Types []NotificationType `json:"types,omitempty"`
|
||||
Statuses []NotificationStatus `json:"statuses,omitempty"`
|
||||
Recipients []string `json:"recipients,omitempty"`
|
||||
CreatedAfter *time.Time `json:"created_after,omitempty"`
|
||||
CreatedBefore *time.Time `json:"created_before,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Notifier is the core interface that all notification implementations must satisfy
|
||||
type Notifier interface {
|
||||
// Send sends a notification and returns the result
|
||||
Send(ctx context.Context, notification *Notification) (*NotificationResult, error)
|
||||
|
||||
// Type returns the notification type this notifier handles
|
||||
Type() NotificationType
|
||||
|
||||
// Validate checks if a notification can be sent with this notifier
|
||||
Validate(notification *Notification) error
|
||||
|
||||
// Close performs cleanup when the notifier is no longer needed
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NotifierFactory creates notifier instances based on configuration
|
||||
type NotifierFactory interface {
|
||||
// Create creates a notifier for the given type
|
||||
Create(notificationType NotificationType) (Notifier, error)
|
||||
|
||||
// RegisterNotifier registers a custom notifier implementation
|
||||
RegisterNotifier(notificationType NotificationType, notifier Notifier) error
|
||||
|
||||
// SupportedTypes returns all supported notification types
|
||||
SupportedTypes() []NotificationType
|
||||
}
|
||||
|
||||
// NotificationService is the high-level service interface for managing notifications
|
||||
type NotificationService interface {
|
||||
// Send queues a notification for delivery
|
||||
Send(ctx context.Context, notification *Notification) (*NotificationResult, error)
|
||||
|
||||
// SendBatch queues multiple notifications for delivery
|
||||
SendBatch(ctx context.Context, notifications []*Notification) ([]*NotificationResult, error)
|
||||
|
||||
// GetNotification retrieves a notification by ID
|
||||
GetNotification(ctx context.Context, id string) (*Notification, error)
|
||||
|
||||
// ListNotifications retrieves notifications matching the filter
|
||||
ListNotifications(ctx context.Context, filter *NotificationFilter) ([]*Notification, error)
|
||||
|
||||
// CancelNotification cancels a pending notification
|
||||
CancelNotification(ctx context.Context, id string) error
|
||||
|
||||
// RetryNotification retries a failed notification
|
||||
RetryNotification(ctx context.Context, id string) (*NotificationResult, error)
|
||||
|
||||
// GetStats returns notification statistics
|
||||
GetStats(ctx context.Context) (*NotificationStats, error)
|
||||
}
|
||||
|
||||
// NotificationStats contains statistics about notification processing
|
||||
type NotificationStats struct {
|
||||
TotalSent int64 `json:"total_sent"`
|
||||
TotalFailed int64 `json:"total_failed"`
|
||||
TotalPending int64 `json:"total_pending"`
|
||||
TotalQueued int64 `json:"total_queued"`
|
||||
ByType map[string]int64 `json:"by_type"`
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
AverageLatency float64 `json:"average_latency_ms"`
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// QueueMessage wraps a notification with queue-specific metadata
|
||||
type QueueMessage struct {
|
||||
// ID is a unique identifier for this queue message
|
||||
ID string `json:"id"`
|
||||
|
||||
// Notification is the actual notification to be sent
|
||||
Notification *Notification `json:"notification"`
|
||||
|
||||
// Attempt is the current delivery attempt number
|
||||
Attempt int `json:"attempt"`
|
||||
|
||||
// EnqueuedAt is when the message was added to the queue
|
||||
EnqueuedAt int64 `json:"enqueued_at"`
|
||||
}
|
||||
|
||||
// Queue defines the interface for a notification queue
|
||||
type Queue interface {
|
||||
// Enqueue adds a notification to the queue
|
||||
Enqueue(ctx context.Context, notification *Notification) error
|
||||
|
||||
// EnqueueBatch adds multiple notifications to the queue
|
||||
EnqueueBatch(ctx context.Context, notifications []*Notification) error
|
||||
|
||||
// Dequeue retrieves the next notification from the queue
|
||||
// Returns nil if the queue is empty
|
||||
Dequeue(ctx context.Context) (*QueueMessage, error)
|
||||
|
||||
// Ack acknowledges successful processing of a message
|
||||
Ack(ctx context.Context, messageID string) error
|
||||
|
||||
// Nack indicates processing failure and may requeue the message
|
||||
Nack(ctx context.Context, messageID string, requeue bool) error
|
||||
|
||||
// Size returns the current number of messages in the queue
|
||||
Size(ctx context.Context) (int64, error)
|
||||
|
||||
// Purge removes all messages from the queue
|
||||
Purge(ctx context.Context) error
|
||||
|
||||
// Close cleanly shuts down the queue
|
||||
Close() error
|
||||
|
||||
// HealthCheck verifies the queue is operational
|
||||
HealthCheck(ctx context.Context) error
|
||||
}
|
||||
|
||||
// QueueConfig contains configuration for queue implementations
|
||||
type QueueConfig struct {
|
||||
// Type specifies the queue implementation (local, kafka, etc.)
|
||||
Type string `mapstructure:"type"`
|
||||
|
||||
// MaxSize is the maximum number of messages the queue can hold
|
||||
MaxSize int64 `mapstructure:"max_size"`
|
||||
|
||||
// WorkerCount is the number of concurrent workers processing the queue
|
||||
WorkerCount int `mapstructure:"worker_count"`
|
||||
|
||||
// RetryAttempts is the number of times to retry failed notifications
|
||||
RetryAttempts int `mapstructure:"retry_attempts"`
|
||||
|
||||
// RetryBackoff is the backoff strategy for retries (exponential, linear, fixed)
|
||||
RetryBackoff string `mapstructure:"retry_backoff"`
|
||||
|
||||
// Local queue specific config
|
||||
Local *LocalQueueConfig `mapstructure:"local,omitempty"`
|
||||
|
||||
// Kafka specific config
|
||||
Kafka *KafkaQueueConfig `mapstructure:"kafka,omitempty"`
|
||||
}
|
||||
|
||||
// LocalQueueConfig contains configuration for the in-memory queue
|
||||
type LocalQueueConfig struct {
|
||||
// BufferSize is the channel buffer size
|
||||
BufferSize int `mapstructure:"buffer_size"`
|
||||
|
||||
// PersistToDisk enables writing queue state to disk for recovery
|
||||
PersistToDisk bool `mapstructure:"persist_to_disk"`
|
||||
|
||||
// PersistPath is where to store the queue state
|
||||
PersistPath string `mapstructure:"persist_path"`
|
||||
}
|
||||
|
||||
// KafkaQueueConfig contains configuration for Kafka queue
|
||||
type KafkaQueueConfig struct {
|
||||
// Brokers is the list of Kafka broker addresses
|
||||
Brokers []string `mapstructure:"brokers"`
|
||||
|
||||
// Topic is the Kafka topic for notifications
|
||||
Topic string `mapstructure:"topic"`
|
||||
|
||||
// ConsumerGroup is the Kafka consumer group ID
|
||||
ConsumerGroup string `mapstructure:"consumer_group"`
|
||||
|
||||
// PartitionCount is the number of partitions for the topic
|
||||
PartitionCount int `mapstructure:"partition_count"`
|
||||
|
||||
// ReplicationFactor is the replication factor for the topic
|
||||
ReplicationFactor int `mapstructure:"replication_factor"`
|
||||
|
||||
// EnableIdempotence ensures exactly-once delivery semantics
|
||||
EnableIdempotence bool `mapstructure:"enable_idempotence"`
|
||||
|
||||
// CompressionType defines compression (none, gzip, snappy, lz4, zstd)
|
||||
CompressionType string `mapstructure:"compression_type"`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// Factory creates and manages notifier instances
|
||||
type Factory struct {
|
||||
notifiers map[domain.NotificationType]domain.Notifier
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewFactory creates a new notifier factory
|
||||
func NewFactory() *Factory {
|
||||
return &Factory{
|
||||
notifiers: make(map[domain.NotificationType]domain.Notifier),
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a notifier for the given type
|
||||
func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
notifier, exists := f.notifiers[notificationType]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported notification type: %s", notificationType)
|
||||
}
|
||||
|
||||
return notifier, nil
|
||||
}
|
||||
|
||||
// RegisterNotifier registers a custom notifier implementation
|
||||
func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if _, exists := f.notifiers[notificationType]; exists {
|
||||
return fmt.Errorf("notifier already registered for type: %s", notificationType)
|
||||
}
|
||||
|
||||
f.notifiers[notificationType] = notifier
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportedTypes returns all supported notification types
|
||||
func (f *Factory) SupportedTypes() []domain.NotificationType {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
types := make([]domain.NotificationType, 0, len(f.notifiers))
|
||||
for t := range f.notifiers {
|
||||
types = append(types, t)
|
||||
}
|
||||
|
||||
return types
|
||||
}
|
||||
|
||||
// BaseNotifier provides common functionality for all notifiers
|
||||
type BaseNotifier struct {
|
||||
notificationType domain.NotificationType
|
||||
}
|
||||
|
||||
// Type returns the notification type
|
||||
func (b *BaseNotifier) Type() domain.NotificationType {
|
||||
return b.notificationType
|
||||
}
|
||||
|
||||
// Validate performs basic validation common to all notifiers
|
||||
func (b *BaseNotifier) Validate(notification *domain.Notification) error {
|
||||
if notification == nil {
|
||||
return fmt.Errorf("notification is nil")
|
||||
}
|
||||
|
||||
if len(notification.Recipients) == 0 {
|
||||
return fmt.Errorf("notification has no recipients")
|
||||
}
|
||||
|
||||
if notification.Type != b.notificationType {
|
||||
return fmt.Errorf("notification type mismatch: expected %s, got %s", b.notificationType, notification.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close performs cleanup (default implementation does nothing)
|
||||
func (b *BaseNotifier) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateContext checks if the context is valid
|
||||
func ValidateContext(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("context is nil")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// NtfyConfig contains ntfy.sh configuration
|
||||
type NtfyConfig struct {
|
||||
// ServerURL is the ntfy server URL (default: https://ntfy.sh)
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
|
||||
// Token is the access token for authentication (preferred method)
|
||||
// Supports both regular tokens (tk_...) and publish tokens
|
||||
Token string `mapstructure:"token"`
|
||||
|
||||
// Username for basic authentication (alternative to token)
|
||||
Username string `mapstructure:"username"`
|
||||
|
||||
// Password for basic authentication (alternative to token)
|
||||
Password string `mapstructure:"password"`
|
||||
|
||||
// DefaultTopic is the default topic if not specified in notification
|
||||
DefaultTopic string `mapstructure:"default_topic"`
|
||||
|
||||
// InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs)
|
||||
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
|
||||
}
|
||||
|
||||
// NtfyNotifier sends notifications via ntfy.sh
|
||||
type NtfyNotifier struct {
|
||||
BaseNotifier
|
||||
config *NtfyConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// ntfyRequest represents the ntfy API request format
|
||||
type ntfyRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Click string `json:"click,omitempty"`
|
||||
Attach string `json:"attach,omitempty"`
|
||||
Actions []ntfyAction `json:"actions,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Delay string `json:"delay,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// ntfyAction represents an action button in ntfy
|
||||
type ntfyAction struct {
|
||||
Action string `json:"action"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Clear bool `json:"clear,omitempty"`
|
||||
}
|
||||
|
||||
// NewNtfyNotifier creates a new ntfy notifier
|
||||
func NewNtfyNotifier(config *NtfyConfig) (*NtfyNotifier, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("ntfy config is required")
|
||||
}
|
||||
|
||||
if config.ServerURL == "" {
|
||||
config.ServerURL = "https://ntfy.sh" // Default public ntfy server
|
||||
}
|
||||
|
||||
// Create HTTP client with optional TLS skip verify
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
if config.InsecureSkipVerify {
|
||||
// For self-hosted servers with self-signed certificates
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
httpClient.Transport = transport
|
||||
}
|
||||
|
||||
return &NtfyNotifier{
|
||||
BaseNotifier: BaseNotifier{
|
||||
notificationType: domain.TypeNtfy,
|
||||
},
|
||||
config: config,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends a notification via ntfy
|
||||
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||
if err := ValidateContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := n.Validate(notification); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For ntfy, recipients are topics
|
||||
recipients := notification.Recipients
|
||||
if len(recipients) == 0 && n.config.DefaultTopic != "" {
|
||||
recipients = []string{n.config.DefaultTopic}
|
||||
}
|
||||
|
||||
for _, topic := range recipients {
|
||||
req := ntfyRequest{
|
||||
Topic: topic,
|
||||
Message: notification.Body,
|
||||
Title: notification.Subject,
|
||||
Priority: n.mapPriority(notification.Priority),
|
||||
}
|
||||
|
||||
// Add custom tags from metadata
|
||||
if tags, ok := notification.Metadata["tags"].([]interface{}); ok {
|
||||
for _, tag := range tags {
|
||||
if tagStr, ok := tag.(string); ok {
|
||||
req.Tags = append(req.Tags, tagStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add click action from metadata
|
||||
if click, ok := notification.Metadata["click"].(string); ok {
|
||||
req.Click = click
|
||||
}
|
||||
|
||||
// Add attachment from metadata
|
||||
if attach, ok := notification.Metadata["attach"].(string); ok {
|
||||
req.Attach = attach
|
||||
}
|
||||
|
||||
// Add icon from metadata
|
||||
if icon, ok := notification.Metadata["icon"].(string); ok {
|
||||
req.Icon = icon
|
||||
}
|
||||
|
||||
// Add delay from metadata (e.g., "30s", "1m", "1h")
|
||||
if delay, ok := notification.Metadata["delay"].(string); ok {
|
||||
req.Delay = delay
|
||||
}
|
||||
|
||||
// Add email from metadata (for email notifications)
|
||||
if email, ok := notification.Metadata["email"].(string); ok {
|
||||
req.Email = email
|
||||
}
|
||||
|
||||
// Add actions from metadata
|
||||
if actions, ok := notification.Metadata["actions"].([]interface{}); ok {
|
||||
for _, action := range actions {
|
||||
if actionMap, ok := action.(map[string]interface{}); ok {
|
||||
ntfyAct := ntfyAction{}
|
||||
if actionType, ok := actionMap["action"].(string); ok {
|
||||
ntfyAct.Action = actionType
|
||||
}
|
||||
if label, ok := actionMap["label"].(string); ok {
|
||||
ntfyAct.Label = label
|
||||
}
|
||||
if url, ok := actionMap["url"].(string); ok {
|
||||
ntfyAct.URL = url
|
||||
}
|
||||
if body, ok := actionMap["body"].(string); ok {
|
||||
ntfyAct.Body = body
|
||||
}
|
||||
if clear, ok := actionMap["clear"].(bool); ok {
|
||||
ntfyAct.Clear = clear
|
||||
}
|
||||
req.Actions = append(req.Actions, ntfyAct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := n.sendToTopic(ctx, &req); err != nil {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
SentAt: time.Now(),
|
||||
}, err
|
||||
}
|
||||
}
|
||||
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Notification sent to %d topics", len(notification.Recipients)),
|
||||
SentAt: time.Now(),
|
||||
ProviderResponse: map[string]interface{}{
|
||||
"server": n.config.ServerURL,
|
||||
"topics": notification.Recipients,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sendToTopic sends a notification to a specific ntfy topic
|
||||
func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error {
|
||||
url := fmt.Sprintf("%s", n.config.ServerURL)
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal ntfy request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add authentication if configured
|
||||
if n.config.Token != "" {
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.config.Token))
|
||||
} else if n.config.Username != "" && n.config.Password != "" {
|
||||
httpReq.SetBasicAuth(n.config.Username, n.config.Password)
|
||||
}
|
||||
|
||||
resp, err := n.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send ntfy notification: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("ntfy server returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapPriority maps domain priority to ntfy priority (1-5)
|
||||
func (n *NtfyNotifier) mapPriority(priority domain.Priority) int {
|
||||
switch priority {
|
||||
case domain.PriorityLow:
|
||||
return 2
|
||||
case domain.PriorityNormal:
|
||||
return 3
|
||||
case domain.PriorityHigh:
|
||||
return 4
|
||||
case domain.PriorityCritical:
|
||||
return 5
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the HTTP client
|
||||
func (n *NtfyNotifier) Close() error {
|
||||
n.httpClient.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// SlackConfig contains Slack webhook configuration
|
||||
type SlackConfig struct {
|
||||
WebhookURL string `mapstructure:"webhook_url"`
|
||||
Token string `mapstructure:"token"`
|
||||
Channel string `mapstructure:"channel"`
|
||||
Username string `mapstructure:"username"`
|
||||
IconEmoji string `mapstructure:"icon_emoji"`
|
||||
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
|
||||
}
|
||||
|
||||
// SlackNotifier sends notifications to Slack
|
||||
type SlackNotifier struct {
|
||||
BaseNotifier
|
||||
config *SlackConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// slackMessage represents the Slack API request format
|
||||
type slackMessage struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Blocks []slackBlock `json:"blocks,omitempty"`
|
||||
Markdown bool `json:"mrkdwn,omitempty"`
|
||||
}
|
||||
|
||||
// slackBlock represents a Slack block element
|
||||
type slackBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *slackTextBlock `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// slackTextBlock represents a text element in a Slack block
|
||||
type slackTextBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// NewSlackNotifier creates a new Slack notifier
|
||||
func NewSlackNotifier(config *SlackConfig) (*SlackNotifier, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("Slack config is required")
|
||||
}
|
||||
|
||||
// Either webhook URL or token is required
|
||||
if config.WebhookURL == "" && config.Token == "" && len(config.Webhooks) == 0 {
|
||||
return nil, fmt.Errorf("Slack webhook URL, token, or channel webhooks are required")
|
||||
}
|
||||
|
||||
return &SlackNotifier{
|
||||
BaseNotifier: BaseNotifier{
|
||||
notificationType: domain.TypeSlack,
|
||||
},
|
||||
config: config,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends a notification to Slack
|
||||
func (s *SlackNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||
if err := ValidateContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.Validate(notification); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For Slack, recipients are channel names or webhook URLs
|
||||
for _, recipient := range notification.Recipients {
|
||||
msg := s.buildMessage(notification, recipient)
|
||||
webhookURL := s.getWebhookURL(recipient)
|
||||
|
||||
if err := s.sendToSlack(ctx, webhookURL, msg); err != nil {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
SentAt: time.Now(),
|
||||
}, err
|
||||
}
|
||||
}
|
||||
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Slack notification sent to %d channels", len(notification.Recipients)),
|
||||
SentAt: time.Now(),
|
||||
ProviderResponse: map[string]interface{}{
|
||||
"channels": notification.Recipients,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildMessage constructs a Slack message with rich formatting
|
||||
func (s *SlackNotifier) buildMessage(notification *domain.Notification, channel string) *slackMessage {
|
||||
msg := &slackMessage{
|
||||
Channel: channel,
|
||||
Username: s.config.Username,
|
||||
IconEmoji: s.config.IconEmoji,
|
||||
Markdown: true,
|
||||
}
|
||||
|
||||
// Use blocks for rich formatting if both subject and body exist
|
||||
if notification.Subject != "" && notification.Body != "" {
|
||||
msg.Blocks = []slackBlock{
|
||||
{
|
||||
Type: "header",
|
||||
Text: &slackTextBlock{
|
||||
Type: "plain_text",
|
||||
Text: notification.Subject,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "section",
|
||||
Text: &slackTextBlock{
|
||||
Type: "mrkdwn",
|
||||
Text: notification.Body,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple text
|
||||
if notification.Subject != "" {
|
||||
msg.Text = fmt.Sprintf("*%s*\n%s", notification.Subject, notification.Body)
|
||||
} else {
|
||||
msg.Text = notification.Body
|
||||
}
|
||||
}
|
||||
|
||||
// Add priority indicator for high priority notifications
|
||||
if notification.Priority >= domain.PriorityHigh {
|
||||
priorityEmoji := ":warning:"
|
||||
if notification.Priority == domain.PriorityCritical {
|
||||
priorityEmoji = ":rotating_light:"
|
||||
}
|
||||
|
||||
msg.Blocks = append([]slackBlock{
|
||||
{
|
||||
Type: "context",
|
||||
Text: &slackTextBlock{
|
||||
Type: "mrkdwn",
|
||||
Text: fmt.Sprintf("%s *Priority: %d*", priorityEmoji, notification.Priority),
|
||||
},
|
||||
},
|
||||
}, msg.Blocks...)
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// getWebhookURL returns the webhook URL for a specific channel
|
||||
func (s *SlackNotifier) getWebhookURL(channel string) string {
|
||||
// Check for channel-specific webhook
|
||||
if webhook, ok := s.config.Webhooks[channel]; ok {
|
||||
return webhook
|
||||
}
|
||||
|
||||
// Fall back to default webhook URL
|
||||
return s.config.WebhookURL
|
||||
}
|
||||
|
||||
// sendToSlack sends the message to Slack via webhook
|
||||
func (s *SlackNotifier) sendToSlack(ctx context.Context, webhookURL string, msg *slackMessage) error {
|
||||
jsonData, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Slack message: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add token authentication if configured
|
||||
if s.config.Token != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.Token))
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send Slack notification: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Slack API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the HTTP client
|
||||
func (s *SlackNotifier) Close() error {
|
||||
s.httpClient.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// SMTPConfig contains SMTP server configuration
|
||||
type SMTPConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
From string `mapstructure:"from"`
|
||||
UseTLS bool `mapstructure:"use_tls"`
|
||||
}
|
||||
|
||||
// SMTPNotifier sends notifications via email using SMTP
|
||||
type SMTPNotifier struct {
|
||||
BaseNotifier
|
||||
config *SMTPConfig
|
||||
}
|
||||
|
||||
// NewSMTPNotifier creates a new SMTP notifier
|
||||
func NewSMTPNotifier(config *SMTPConfig) (*SMTPNotifier, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("SMTP config is required")
|
||||
}
|
||||
|
||||
if config.Host == "" {
|
||||
return nil, fmt.Errorf("SMTP host is required")
|
||||
}
|
||||
|
||||
if config.Port == 0 {
|
||||
config.Port = 587 // Default SMTP submission port
|
||||
}
|
||||
|
||||
if config.From == "" {
|
||||
return nil, fmt.Errorf("SMTP from address is required")
|
||||
}
|
||||
|
||||
return &SMTPNotifier{
|
||||
BaseNotifier: BaseNotifier{
|
||||
notificationType: domain.TypeEmail,
|
||||
},
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends a notification via email
|
||||
func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||
if err := ValidateContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.Validate(notification); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate email recipients
|
||||
for _, recipient := range notification.Recipients {
|
||||
if !strings.Contains(recipient, "@") {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid email address: %s", recipient),
|
||||
SentAt: time.Now(),
|
||||
}, fmt.Errorf("invalid email address: %s", recipient)
|
||||
}
|
||||
}
|
||||
|
||||
// Build email message
|
||||
message := s.buildMessage(notification)
|
||||
|
||||
// Send email
|
||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||
|
||||
err := smtp.SendMail(addr, auth, s.config.From, notification.Recipients, []byte(message))
|
||||
if err != nil {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
SentAt: time.Now(),
|
||||
}, fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Email sent to %d recipients", len(notification.Recipients)),
|
||||
SentAt: time.Now(),
|
||||
ProviderResponse: map[string]interface{}{
|
||||
"smtp_server": addr,
|
||||
"from": s.config.From,
|
||||
"to": notification.Recipients,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildMessage constructs the email message with headers
|
||||
func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("From: %s\r\n", s.config.From))
|
||||
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
|
||||
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
|
||||
builder.WriteString("MIME-Version: 1.0\r\n")
|
||||
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
builder.WriteString("\r\n")
|
||||
builder.WriteString(notification.Body)
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// Validate checks if the notification is valid for SMTP
|
||||
func (s *SMTPNotifier) Validate(notification *domain.Notification) error {
|
||||
if err := s.BaseNotifier.Validate(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if notification.Subject == "" {
|
||||
return fmt.Errorf("email subject is required")
|
||||
}
|
||||
|
||||
if notification.Body == "" {
|
||||
return fmt.Errorf("email body is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// StdoutNotifier sends notifications to stdout (useful for debugging)
|
||||
type StdoutNotifier struct {
|
||||
BaseNotifier
|
||||
}
|
||||
|
||||
// NewStdoutNotifier creates a new stdout notifier
|
||||
func NewStdoutNotifier() *StdoutNotifier {
|
||||
return &StdoutNotifier{
|
||||
BaseNotifier: BaseNotifier{
|
||||
notificationType: domain.TypeStdout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a notification to stdout
|
||||
func (s *StdoutNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||
if err := ValidateContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.Validate(notification); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("Notification ID: %s\n", notification.ID)
|
||||
fmt.Printf("Type: %s\n", notification.Type)
|
||||
fmt.Printf("Priority: %d\n", notification.Priority)
|
||||
fmt.Printf("Recipients: %v\n", notification.Recipients)
|
||||
fmt.Printf("Subject: %s\n", notification.Subject)
|
||||
fmt.Printf("Body:\n%s\n", notification.Body)
|
||||
fmt.Println("========================================")
|
||||
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: true,
|
||||
Message: "Notification printed to stdout",
|
||||
SentAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LocalQueue is an in-memory queue implementation
|
||||
type LocalQueue struct {
|
||||
queue chan *domain.QueueMessage
|
||||
messages map[string]*domain.QueueMessage
|
||||
mu sync.RWMutex
|
||||
config *domain.LocalQueueConfig
|
||||
persistToDisk bool
|
||||
persistPath string
|
||||
closed bool
|
||||
closeChan chan struct{}
|
||||
}
|
||||
|
||||
// NewLocalQueue creates a new local queue instance
|
||||
func NewLocalQueue(config *domain.LocalQueueConfig) (*LocalQueue, error) {
|
||||
if config == nil {
|
||||
config = &domain.LocalQueueConfig{
|
||||
BufferSize: 1000,
|
||||
PersistToDisk: false,
|
||||
}
|
||||
}
|
||||
|
||||
lq := &LocalQueue{
|
||||
queue: make(chan *domain.QueueMessage, config.BufferSize),
|
||||
messages: make(map[string]*domain.QueueMessage),
|
||||
config: config,
|
||||
persistToDisk: config.PersistToDisk,
|
||||
persistPath: config.PersistPath,
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Load persisted messages if enabled
|
||||
if lq.persistToDisk && lq.persistPath != "" {
|
||||
if err := lq.loadFromDisk(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load persisted queue: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return lq, nil
|
||||
}
|
||||
|
||||
// Enqueue adds a notification to the queue
|
||||
func (lq *LocalQueue) Enqueue(ctx context.Context, notification *domain.Notification) error {
|
||||
lq.mu.Lock()
|
||||
defer lq.mu.Unlock()
|
||||
|
||||
if lq.closed {
|
||||
return fmt.Errorf("queue is closed")
|
||||
}
|
||||
|
||||
msg := &domain.QueueMessage{
|
||||
ID: uuid.New().String(),
|
||||
Notification: notification,
|
||||
Attempt: 0,
|
||||
EnqueuedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
select {
|
||||
case lq.queue <- msg:
|
||||
lq.messages[msg.ID] = msg
|
||||
notification.Status = domain.StatusQueued
|
||||
|
||||
if lq.persistToDisk {
|
||||
return lq.persistToDiskSync()
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-lq.closeChan:
|
||||
return fmt.Errorf("queue is closed")
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueBatch adds multiple notifications to the queue
|
||||
func (lq *LocalQueue) EnqueueBatch(ctx context.Context, notifications []*domain.Notification) error {
|
||||
lq.mu.Lock()
|
||||
defer lq.mu.Unlock()
|
||||
|
||||
if lq.closed {
|
||||
return fmt.Errorf("queue is closed")
|
||||
}
|
||||
|
||||
for _, notification := range notifications {
|
||||
msg := &domain.QueueMessage{
|
||||
ID: uuid.New().String(),
|
||||
Notification: notification,
|
||||
Attempt: 0,
|
||||
EnqueuedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
select {
|
||||
case lq.queue <- msg:
|
||||
lq.messages[msg.ID] = msg
|
||||
notification.Status = domain.StatusQueued
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-lq.closeChan:
|
||||
return fmt.Errorf("queue is closed")
|
||||
}
|
||||
}
|
||||
|
||||
if lq.persistToDisk {
|
||||
return lq.persistToDiskSync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dequeue retrieves the next notification from the queue
|
||||
func (lq *LocalQueue) Dequeue(ctx context.Context) (*domain.QueueMessage, error) {
|
||||
if lq.closed {
|
||||
return nil, fmt.Errorf("queue is closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-lq.queue:
|
||||
lq.mu.Lock()
|
||||
msg.Attempt++
|
||||
msg.Notification.Status = domain.StatusProcessing
|
||||
lq.mu.Unlock()
|
||||
return msg, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-lq.closeChan:
|
||||
return nil, fmt.Errorf("queue is closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Ack acknowledges successful processing of a message
|
||||
func (lq *LocalQueue) Ack(ctx context.Context, messageID string) error {
|
||||
lq.mu.Lock()
|
||||
defer lq.mu.Unlock()
|
||||
|
||||
if msg, exists := lq.messages[messageID]; exists {
|
||||
msg.Notification.Status = domain.StatusSent
|
||||
delete(lq.messages, messageID)
|
||||
|
||||
if lq.persistToDisk {
|
||||
return lq.persistToDiskSync()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nack indicates processing failure and may requeue the message
|
||||
func (lq *LocalQueue) Nack(ctx context.Context, messageID string, requeue bool) error {
|
||||
lq.mu.Lock()
|
||||
defer lq.mu.Unlock()
|
||||
|
||||
msg, exists := lq.messages[messageID]
|
||||
if !exists {
|
||||
return fmt.Errorf("message not found: %s", messageID)
|
||||
}
|
||||
|
||||
if requeue {
|
||||
msg.Notification.Status = domain.StatusRetrying
|
||||
select {
|
||||
case lq.queue <- msg:
|
||||
if lq.persistToDisk {
|
||||
return lq.persistToDiskSync()
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-lq.closeChan:
|
||||
return fmt.Errorf("queue is closed")
|
||||
}
|
||||
} else {
|
||||
msg.Notification.Status = domain.StatusFailed
|
||||
delete(lq.messages, messageID)
|
||||
|
||||
if lq.persistToDisk {
|
||||
return lq.persistToDiskSync()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the current number of messages in the queue
|
||||
func (lq *LocalQueue) Size(ctx context.Context) (int64, error) {
|
||||
lq.mu.RLock()
|
||||
defer lq.mu.RUnlock()
|
||||
return int64(len(lq.queue)), nil
|
||||
}
|
||||
|
||||
// Purge removes all messages from the queue
|
||||
func (lq *LocalQueue) Purge(ctx context.Context) error {
|
||||
lq.mu.Lock()
|
||||
defer lq.mu.Unlock()
|
||||
|
||||
// Drain the channel
|
||||
for len(lq.queue) > 0 {
|
||||
<-lq.queue
|
||||
}
|
||||
|
||||
lq.messages = make(map[string]*domain.QueueMessage)
|
||||
|
||||
if lq.persistToDisk {
|
||||
return lq.persistToDiskSync()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close cleanly shuts down the queue
|
||||
func (lq *LocalQueue) Close() error {
|
||||
lq.mu.Lock()
|
||||
defer lq.mu.Unlock()
|
||||
|
||||
if lq.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
lq.closed = true
|
||||
close(lq.closeChan)
|
||||
|
||||
if lq.persistToDisk {
|
||||
if err := lq.persistToDiskSync(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
close(lq.queue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck verifies the queue is operational
|
||||
func (lq *LocalQueue) HealthCheck(ctx context.Context) error {
|
||||
lq.mu.RLock()
|
||||
defer lq.mu.RUnlock()
|
||||
|
||||
if lq.closed {
|
||||
return fmt.Errorf("queue is closed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistToDiskSync persists the queue state to disk (must be called with lock held)
|
||||
func (lq *LocalQueue) persistToDiskSync() error {
|
||||
if !lq.persistToDisk || lq.persistPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(lq.messages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal queue state: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(lq.persistPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write queue state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadFromDisk loads the queue state from disk
|
||||
func (lq *LocalQueue) loadFromDisk() error {
|
||||
if lq.persistPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(lq.persistPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No persisted state yet
|
||||
}
|
||||
return fmt.Errorf("failed to read queue state: %w", err)
|
||||
}
|
||||
|
||||
var messages map[string]*domain.QueueMessage
|
||||
if err := json.Unmarshal(data, &messages); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal queue state: %w", err)
|
||||
}
|
||||
|
||||
// Re-enqueue persisted messages
|
||||
for _, msg := range messages {
|
||||
lq.queue <- msg
|
||||
lq.messages[msg.ID] = msg
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// NotificationService implements the domain.NotificationService interface
|
||||
type NotificationService struct {
|
||||
factory domain.NotifierFactory
|
||||
queue domain.Queue
|
||||
notifications map[string]*domain.Notification
|
||||
mu sync.RWMutex
|
||||
workerCount int
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service
|
||||
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int) *NotificationService {
|
||||
if workerCount <= 0 {
|
||||
workerCount = 10
|
||||
}
|
||||
|
||||
return &NotificationService{
|
||||
factory: factory,
|
||||
queue: queue,
|
||||
notifications: make(map[string]*domain.Notification),
|
||||
workerCount: workerCount,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the worker pool
|
||||
func (s *NotificationService) Start(ctx context.Context) error {
|
||||
for i := 0; i < s.workerCount; i++ {
|
||||
s.wg.Add(1)
|
||||
go s.worker(ctx, i)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the service gracefully
|
||||
func (s *NotificationService) Stop() error {
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
return s.queue.Close()
|
||||
}
|
||||
|
||||
// worker processes notifications from the queue
|
||||
func (s *NotificationService) worker(ctx context.Context, id int) {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// Try to dequeue with timeout
|
||||
workerCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
msg, err := s.queue.Dequeue(workerCtx)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
if err == context.DeadlineExceeded {
|
||||
continue
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg == nil {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the notification
|
||||
s.processNotification(ctx, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processNotification sends a notification and handles the result
|
||||
func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) {
|
||||
notification := msg.Notification
|
||||
|
||||
// Get the appropriate notifier
|
||||
notifier, err := s.factory.Create(notification.Type)
|
||||
if err != nil {
|
||||
notification.Status = domain.StatusFailed
|
||||
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)
|
||||
s.queue.Nack(ctx, msg.ID, false)
|
||||
s.updateNotification(notification)
|
||||
return
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
result, err := notifier.Send(ctx, notification)
|
||||
if err != nil || !result.Success {
|
||||
notification.RetryCount++
|
||||
notification.LastError = result.Error
|
||||
if err != nil {
|
||||
notification.LastError = err.Error()
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
if notification.RetryCount < notification.MaxRetries {
|
||||
notification.Status = domain.StatusRetrying
|
||||
s.queue.Nack(ctx, msg.ID, true) // Requeue
|
||||
} else {
|
||||
notification.Status = domain.StatusFailed
|
||||
s.queue.Nack(ctx, msg.ID, false) // Don't requeue
|
||||
}
|
||||
} else {
|
||||
notification.Status = domain.StatusSent
|
||||
now := time.Now()
|
||||
notification.SentAt = &now
|
||||
s.queue.Ack(ctx, msg.ID)
|
||||
}
|
||||
|
||||
s.updateNotification(notification)
|
||||
}
|
||||
|
||||
// Send queues a notification for delivery
|
||||
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||
// Store the notification
|
||||
s.storeNotification(notification)
|
||||
|
||||
// Enqueue for processing
|
||||
if err := s.queue.Enqueue(ctx, notification); err != nil {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to enqueue: %v", err),
|
||||
SentAt: time.Now(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: true,
|
||||
Message: "notification queued successfully",
|
||||
SentAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendBatch queues multiple notifications for delivery
|
||||
func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
|
||||
results := make([]*domain.NotificationResult, 0, len(notifications))
|
||||
|
||||
// Store all notifications
|
||||
for _, notification := range notifications {
|
||||
s.storeNotification(notification)
|
||||
}
|
||||
|
||||
// Enqueue batch
|
||||
if err := s.queue.EnqueueBatch(ctx, notifications); err != nil {
|
||||
return nil, fmt.Errorf("failed to enqueue batch: %w", err)
|
||||
}
|
||||
|
||||
// Create results
|
||||
for _, notification := range notifications {
|
||||
results = append(results, &domain.NotificationResult{
|
||||
NotificationID: notification.ID,
|
||||
Success: true,
|
||||
Message: "notification queued successfully",
|
||||
SentAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetNotification retrieves a notification by ID
|
||||
func (s *NotificationService) GetNotification(ctx context.Context, id string) (*domain.Notification, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
notification, exists := s.notifications[id]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("notification not found: %s", id)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
// ListNotifications retrieves notifications matching the filter
|
||||
func (s *NotificationService) ListNotifications(ctx context.Context, filter *domain.NotificationFilter) ([]*domain.Notification, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Simple in-memory filtering
|
||||
var results []*domain.Notification
|
||||
|
||||
for _, notification := range s.notifications {
|
||||
if s.matchesFilter(notification, filter) {
|
||||
results = append(results, notification)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit and offset
|
||||
if filter.Offset > 0 && filter.Offset < len(results) {
|
||||
results = results[filter.Offset:]
|
||||
}
|
||||
|
||||
if filter.Limit > 0 && filter.Limit < len(results) {
|
||||
results = results[:filter.Limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CancelNotification cancels a pending notification
|
||||
func (s *NotificationService) CancelNotification(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
notification, exists := s.notifications[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("notification not found: %s", id)
|
||||
}
|
||||
|
||||
if notification.Status == domain.StatusSent {
|
||||
return fmt.Errorf("notification already sent")
|
||||
}
|
||||
|
||||
notification.Status = domain.StatusFailed
|
||||
notification.LastError = "cancelled by user"
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetryNotification retries a failed notification
|
||||
func (s *NotificationService) RetryNotification(ctx context.Context, id string) (*domain.NotificationResult, error) {
|
||||
notification, err := s.GetNotification(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if notification.Status == domain.StatusSent {
|
||||
return &domain.NotificationResult{
|
||||
NotificationID: id,
|
||||
Success: false,
|
||||
Error: "notification already sent",
|
||||
SentAt: time.Now(),
|
||||
}, fmt.Errorf("notification already sent")
|
||||
}
|
||||
|
||||
// Reset retry count and status
|
||||
notification.RetryCount = 0
|
||||
notification.Status = domain.StatusPending
|
||||
|
||||
// Re-enqueue
|
||||
return s.Send(ctx, notification)
|
||||
}
|
||||
|
||||
// GetStats returns notification statistics
|
||||
func (s *NotificationService) GetStats(ctx context.Context) (*domain.NotificationStats, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stats := &domain.NotificationStats{
|
||||
ByType: make(map[string]int64),
|
||||
ByStatus: make(map[string]int64),
|
||||
}
|
||||
|
||||
for _, notification := range s.notifications {
|
||||
switch notification.Status {
|
||||
case domain.StatusSent:
|
||||
stats.TotalSent++
|
||||
case domain.StatusFailed:
|
||||
stats.TotalFailed++
|
||||
case domain.StatusPending:
|
||||
stats.TotalPending++
|
||||
case domain.StatusQueued:
|
||||
stats.TotalQueued++
|
||||
}
|
||||
|
||||
stats.ByType[string(notification.Type)]++
|
||||
stats.ByStatus[string(notification.Status)]++
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// storeNotification stores a notification in memory
|
||||
func (s *NotificationService) storeNotification(notification *domain.Notification) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.notifications[notification.ID] = notification
|
||||
}
|
||||
|
||||
// updateNotification updates a notification in memory
|
||||
func (s *NotificationService) updateNotification(notification *domain.Notification) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.notifications[notification.ID] = notification
|
||||
}
|
||||
|
||||
// matchesFilter checks if a notification matches the filter
|
||||
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
|
||||
if filter == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check IDs
|
||||
if len(filter.IDs) > 0 {
|
||||
found := false
|
||||
for _, id := range filter.IDs {
|
||||
if notification.ID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check types
|
||||
if len(filter.Types) > 0 {
|
||||
found := false
|
||||
for _, t := range filter.Types {
|
||||
if notification.Type == t {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check statuses
|
||||
if len(filter.Statuses) > 0 {
|
||||
found := false
|
||||
for _, s := range filter.Statuses {
|
||||
if notification.Status == s {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check recipients
|
||||
if len(filter.Recipients) > 0 {
|
||||
found := false
|
||||
for _, fr := range filter.Recipients {
|
||||
for _, nr := range notification.Recipients {
|
||||
if fr == nr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check time ranges
|
||||
if filter.CreatedAfter != nil && notification.CreatedAt.Before(*filter.CreatedAfter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if filter.CreatedBefore != nil && notification.CreatedAt.After(*filter.CreatedBefore) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: notifier-config
|
||||
labels:
|
||||
app: notifier
|
||||
data:
|
||||
config.yaml: |
|
||||
server:
|
||||
grpc_port: 50051
|
||||
rest_port: 8080
|
||||
host: "0.0.0.0"
|
||||
mode: "both"
|
||||
|
||||
queue:
|
||||
type: "local"
|
||||
max_size: 10000
|
||||
worker_count: 10
|
||||
retry_attempts: 3
|
||||
retry_backoff: "exponential"
|
||||
local:
|
||||
buffer_size: 1000
|
||||
persist_to_disk: false
|
||||
|
||||
notifiers:
|
||||
stdout: true
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output_path: "stdout"
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
path: "/metrics"
|
||||
prometheus_enabled: true
|
||||
|
||||
health_check:
|
||||
enabled: true
|
||||
port: 8081
|
||||
path: "/health"
|
||||
interval: 30
|
||||
@@ -0,0 +1,97 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: notifier
|
||||
labels:
|
||||
app: notifier
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: notifier
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: notifier
|
||||
version: v1
|
||||
spec:
|
||||
serviceAccountName: notifier
|
||||
containers:
|
||||
- name: notifier
|
||||
image: notifier:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: rest
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
containerPort: 50051
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
containerPort: 9090
|
||||
protocol: TCP
|
||||
- name: health
|
||||
containerPort: 8081
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NOTIFIER_SERVER_MODE
|
||||
value: "both"
|
||||
- name: NOTIFIER_LOGGING_LEVEL
|
||||
value: "info"
|
||||
- name: NOTIFIER_LOGGING_FORMAT
|
||||
value: "json"
|
||||
- name: NOTIFIER_QUEUE_TYPE
|
||||
value: "local"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/config.yaml
|
||||
subPath: config.yaml
|
||||
readOnly: true
|
||||
- name: queue-storage
|
||||
mountPath: /var/lib/notifier
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: health
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: health
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: notifier-config
|
||||
- name: queue-storage
|
||||
emptyDir: {}
|
||||
restartPolicy: Always
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: notifier
|
||||
labels:
|
||||
app: notifier
|
||||
@@ -0,0 +1,43 @@
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: notifier-hpa
|
||||
labels:
|
||||
app: notifier
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: notifier
|
||||
minReplicas: 3
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 50
|
||||
periodSeconds: 60
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 60
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 60
|
||||
- type: Pods
|
||||
value: 2
|
||||
periodSeconds: 60
|
||||
selectPolicy: Max
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: notifier-ingress
|
||||
labels:
|
||||
app: notifier
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- notifier.example.com
|
||||
secretName: notifier-tls
|
||||
rules:
|
||||
- host: notifier.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: notifier-rest
|
||||
port:
|
||||
number: 8080
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: notifier
|
||||
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- configmap.yaml
|
||||
- ingress.yaml
|
||||
- hpa.yaml
|
||||
|
||||
commonLabels:
|
||||
app: notifier
|
||||
managed-by: kustomize
|
||||
|
||||
images:
|
||||
- name: notifier
|
||||
newName: your-registry/notifier
|
||||
newTag: latest
|
||||
@@ -0,0 +1,45 @@
|
||||
# Example secret for notifier credentials
|
||||
# DO NOT commit actual secrets to version control
|
||||
# Use sealed-secrets, external-secrets, or vault in production
|
||||
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: notifier-secrets
|
||||
labels:
|
||||
app: notifier
|
||||
type: Opaque
|
||||
stringData:
|
||||
# SMTP credentials
|
||||
smtp-username: "your-email@gmail.com"
|
||||
smtp-password: "your-app-password"
|
||||
|
||||
# Slack webhook URL
|
||||
slack-webhook-url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
|
||||
|
||||
# Ntfy token
|
||||
ntfy-token: "tk_your_token"
|
||||
|
||||
# Kafka credentials (if using Kafka)
|
||||
kafka-username: "kafka-user"
|
||||
kafka-password: "kafka-password"
|
||||
|
||||
---
|
||||
# Example of using secrets in deployment
|
||||
# Add this to deployment.yaml under spec.template.spec.containers[0].env
|
||||
|
||||
# - name: NOTIFIER_NOTIFIERS_SMTP_USERNAME
|
||||
# valueFrom:
|
||||
# secretKeyRef:
|
||||
# name: notifier-secrets
|
||||
# key: smtp-username
|
||||
# - name: NOTIFIER_NOTIFIERS_SMTP_PASSWORD
|
||||
# valueFrom:
|
||||
# secretKeyRef:
|
||||
# name: notifier-secrets
|
||||
# key: smtp-password
|
||||
# - name: NOTIFIER_NOTIFIERS_SLACK_WEBHOOK_URL
|
||||
# valueFrom:
|
||||
# secretKeyRef:
|
||||
# name: notifier-secrets
|
||||
# key: slack-webhook-url
|
||||
@@ -0,0 +1,50 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: notifier-rest
|
||||
labels:
|
||||
app: notifier
|
||||
service: rest
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: rest
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: notifier
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: notifier-grpc
|
||||
labels:
|
||||
app: notifier
|
||||
service: grpc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 50051
|
||||
targetPort: grpc
|
||||
protocol: TCP
|
||||
name: grpc
|
||||
selector:
|
||||
app: notifier
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: notifier-metrics
|
||||
labels:
|
||||
app: notifier
|
||||
service: metrics
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 9090
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
selector:
|
||||
app: notifier
|
||||
Reference in New Issue
Block a user