move docs to docs/ folder, merge architecture files, update references
This commit is contained in:
715
docs/DEV_PORTAL_M06_API.md
Normal file
715
docs/DEV_PORTAL_M06_API.md
Normal file
@@ -0,0 +1,715 @@
|
||||
# Milestone 6: App Store Backend API
|
||||
|
||||
**Status**: Decided
|
||||
**Goal**: REST API for app submission, review, and distribution.
|
||||
|
||||
## Decision
|
||||
|
||||
**Go + Chi router** with JSON REST API:
|
||||
|
||||
```
|
||||
Framework: Chi (lightweight, idiomatic)
|
||||
Validation: go-playground/validator/v10
|
||||
OpenAPI: ogen (generated from spec)
|
||||
Middleware: Custom auth, rate limiting, logging
|
||||
Database: SQLite via repository pattern
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ mosis-portal container │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Chi Router │ │
|
||||
│ │ /v1/auth/* /v1/apps/* /v1/store/* /v1/keys/* │ │
|
||||
│ └──────────────────────┬─────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────┬───────────┼───────────┬──────────┐ │
|
||||
│ │ Handlers │ Services │ Repos │ SQLite │ │
|
||||
│ └──────────┴───────────┴───────────┴──────────┘ │
|
||||
│ │
|
||||
│ /volume1/mosis/data/portal.db │
|
||||
│ /volume1/mosis/packages/ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The backend API serves the developer portal, CLI tools, and device-side app management. It handles authentication, app lifecycle, file storage, and telemetry ingestion.
|
||||
|
||||
---
|
||||
|
||||
## API Design Principles
|
||||
|
||||
1. **RESTful** - Standard HTTP methods and status codes
|
||||
2. **JSON** - Request and response bodies in JSON
|
||||
3. **Versioned** - `/v1/` prefix for breaking changes
|
||||
4. **Consistent** - Same patterns across all endpoints
|
||||
5. **Documented** - OpenAPI specification
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
Production: https://api.mosis.dev/v1
|
||||
Staging: https://api.staging.mosis.dev/v1
|
||||
Local: http://localhost:8080/v1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### Headers
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt_token>
|
||||
# or
|
||||
X-API-Key: mk_live_xxxxxxxx
|
||||
```
|
||||
|
||||
### Scopes
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `apps:read` | Read app metadata |
|
||||
| `apps:write` | Create/update apps |
|
||||
| `versions:upload` | Upload new versions |
|
||||
| `versions:publish` | Publish versions |
|
||||
| `telemetry:read` | Read analytics |
|
||||
| `keys:manage` | Manage API keys |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
```yaml
|
||||
POST /v1/auth/oauth/github:
|
||||
summary: Start GitHub OAuth flow
|
||||
response: { redirect_url: string }
|
||||
|
||||
GET /v1/auth/oauth/github/callback:
|
||||
summary: GitHub OAuth callback
|
||||
query:
|
||||
code: string
|
||||
state: string
|
||||
response: { access_token, refresh_token, user }
|
||||
|
||||
POST /v1/auth/oauth/google:
|
||||
summary: Start Google OAuth flow
|
||||
response: { redirect_url: string }
|
||||
|
||||
GET /v1/auth/oauth/google/callback:
|
||||
summary: Google OAuth callback
|
||||
|
||||
POST /v1/auth/refresh:
|
||||
summary: Refresh access token
|
||||
body: { refresh_token: string }
|
||||
response: { access_token, refresh_token }
|
||||
|
||||
POST /v1/auth/logout:
|
||||
summary: Invalidate tokens
|
||||
auth: required
|
||||
|
||||
GET /v1/auth/me:
|
||||
summary: Get current user
|
||||
auth: required
|
||||
response: Developer
|
||||
```
|
||||
|
||||
### Apps
|
||||
|
||||
```yaml
|
||||
GET /v1/apps:
|
||||
summary: List developer's apps
|
||||
auth: required
|
||||
query:
|
||||
status: draft | published | suspended
|
||||
page: number
|
||||
limit: number
|
||||
response: { apps: App[], total: number }
|
||||
|
||||
POST /v1/apps:
|
||||
summary: Create new app
|
||||
auth: required
|
||||
body:
|
||||
package_id: string # com.developer.appname
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
response: App
|
||||
|
||||
GET /v1/apps/:id:
|
||||
summary: Get app details
|
||||
auth: required
|
||||
response: App
|
||||
|
||||
PATCH /v1/apps/:id:
|
||||
summary: Update app metadata
|
||||
auth: required
|
||||
body:
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
tags?: string[]
|
||||
response: App
|
||||
|
||||
DELETE /v1/apps/:id:
|
||||
summary: Delete app (if no published versions)
|
||||
auth: required
|
||||
response: { success: true }
|
||||
```
|
||||
|
||||
### App Versions
|
||||
|
||||
```yaml
|
||||
GET /v1/apps/:id/versions:
|
||||
summary: List app versions
|
||||
auth: required
|
||||
query:
|
||||
status: draft | review | approved | published | rejected
|
||||
page: number
|
||||
limit: number
|
||||
response: { versions: AppVersion[], total: number }
|
||||
|
||||
POST /v1/apps/:id/versions:
|
||||
summary: Create new version (get upload URL)
|
||||
auth: required
|
||||
body:
|
||||
version_name: string # 1.0.0
|
||||
version_code: number # 1
|
||||
release_notes?: string
|
||||
response:
|
||||
version: AppVersion
|
||||
upload_url: string # Presigned S3 URL
|
||||
upload_expires: string # ISO timestamp
|
||||
|
||||
PUT /v1/apps/:id/versions/:vid/upload-complete:
|
||||
summary: Mark upload as complete, trigger validation
|
||||
auth: required
|
||||
response: AppVersion
|
||||
|
||||
GET /v1/apps/:id/versions/:vid:
|
||||
summary: Get version details
|
||||
auth: required
|
||||
response: AppVersion
|
||||
|
||||
POST /v1/apps/:id/versions/:vid/submit:
|
||||
summary: Submit version for review
|
||||
auth: required
|
||||
response: AppVersion # status: review
|
||||
|
||||
POST /v1/apps/:id/versions/:vid/publish:
|
||||
summary: Publish approved version
|
||||
auth: required
|
||||
response: AppVersion # status: published
|
||||
|
||||
DELETE /v1/apps/:id/versions/:vid:
|
||||
summary: Delete draft version
|
||||
auth: required
|
||||
response: { success: true }
|
||||
```
|
||||
|
||||
### Public App Store
|
||||
|
||||
```yaml
|
||||
GET /v1/store/apps:
|
||||
summary: Browse/search published apps
|
||||
auth: none
|
||||
query:
|
||||
q: string # Search query
|
||||
category: string
|
||||
sort: popular | recent | name
|
||||
page: number
|
||||
limit: number
|
||||
response: { apps: PublicApp[], total: number }
|
||||
|
||||
GET /v1/store/apps/:package_id:
|
||||
summary: Get app store listing
|
||||
auth: none
|
||||
response: PublicApp
|
||||
|
||||
GET /v1/store/apps/:package_id/download:
|
||||
summary: Get download URL for latest version
|
||||
auth: none (or device token)
|
||||
response:
|
||||
download_url: string
|
||||
version: string
|
||||
size: number
|
||||
signature: string
|
||||
|
||||
GET /v1/store/apps/:package_id/versions/:version_code/download:
|
||||
summary: Get download URL for specific version
|
||||
auth: none
|
||||
response: { download_url, version, size, signature }
|
||||
```
|
||||
|
||||
### API Keys
|
||||
|
||||
```yaml
|
||||
GET /v1/keys:
|
||||
summary: List API keys
|
||||
auth: required
|
||||
response: { keys: APIKey[] }
|
||||
|
||||
POST /v1/keys:
|
||||
summary: Create API key
|
||||
auth: required
|
||||
body:
|
||||
name: string
|
||||
permissions: string[]
|
||||
expires_at?: string
|
||||
response:
|
||||
key: APIKey
|
||||
secret: string # Only shown once!
|
||||
|
||||
DELETE /v1/keys/:id:
|
||||
summary: Revoke API key
|
||||
auth: required
|
||||
response: { success: true }
|
||||
```
|
||||
|
||||
### Signing Keys
|
||||
|
||||
```yaml
|
||||
GET /v1/signing-keys:
|
||||
summary: List signing keys
|
||||
auth: required
|
||||
response: { keys: SigningKey[] }
|
||||
|
||||
POST /v1/signing-keys:
|
||||
summary: Register signing key
|
||||
auth: required
|
||||
body:
|
||||
name: string
|
||||
public_key: string # PEM format
|
||||
response: SigningKey
|
||||
|
||||
DELETE /v1/signing-keys/:id:
|
||||
summary: Revoke signing key
|
||||
auth: required
|
||||
response: { success: true }
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
|
||||
```yaml
|
||||
POST /v1/telemetry/events:
|
||||
summary: Submit telemetry events (batch)
|
||||
auth: device token or API key
|
||||
body:
|
||||
events:
|
||||
- app_id: string
|
||||
event_type: string
|
||||
event_data: object
|
||||
timestamp: string
|
||||
response: { received: number }
|
||||
|
||||
POST /v1/telemetry/crash:
|
||||
summary: Submit crash report
|
||||
auth: device token or API key
|
||||
body:
|
||||
app_id: string
|
||||
app_version: string
|
||||
crash_type: string
|
||||
message: string
|
||||
stack_trace: string
|
||||
context: object
|
||||
timestamp: string
|
||||
response: { id: string }
|
||||
|
||||
GET /v1/apps/:id/analytics:
|
||||
summary: Get app analytics
|
||||
auth: required
|
||||
query:
|
||||
start_date: string
|
||||
end_date: string
|
||||
metrics: downloads | active_users | crashes
|
||||
response:
|
||||
data:
|
||||
- date: string
|
||||
downloads: number
|
||||
active_users: number
|
||||
crashes: number
|
||||
|
||||
GET /v1/apps/:id/crashes:
|
||||
summary: Get crash reports
|
||||
auth: required
|
||||
query:
|
||||
version?: string
|
||||
page: number
|
||||
limit: number
|
||||
response: { crashes: CrashReport[], total: number }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models (Go)
|
||||
|
||||
### Developer
|
||||
|
||||
```go
|
||||
type Developer struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Verified bool `json:"verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### App
|
||||
|
||||
```go
|
||||
type AppStatus string
|
||||
|
||||
const (
|
||||
AppStatusDraft AppStatus = "draft"
|
||||
AppStatusPublished AppStatus = "published"
|
||||
AppStatusSuspended AppStatus = "suspended"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ID string `json:"id"`
|
||||
PackageID string `json:"package_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Status AppStatus `json:"status"`
|
||||
IconURL *string `json:"icon_url,omitempty"`
|
||||
LatestVersion *AppVersion `json:"latest_version,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### AppVersion
|
||||
|
||||
```go
|
||||
type VersionStatus string
|
||||
|
||||
const (
|
||||
VersionStatusDraft VersionStatus = "draft"
|
||||
VersionStatusUploading VersionStatus = "uploading"
|
||||
VersionStatusValidating VersionStatus = "validating"
|
||||
VersionStatusReview VersionStatus = "review"
|
||||
VersionStatusApproved VersionStatus = "approved"
|
||||
VersionStatusPublished VersionStatus = "published"
|
||||
VersionStatusRejected VersionStatus = "rejected"
|
||||
)
|
||||
|
||||
type AppVersion struct {
|
||||
ID string `json:"id"`
|
||||
AppID string `json:"app_id"`
|
||||
VersionName string `json:"version_name"`
|
||||
VersionCode int `json:"version_code"`
|
||||
PackageURL *string `json:"package_url,omitempty"`
|
||||
PackageSize *int64 `json:"package_size,omitempty"`
|
||||
Signature *string `json:"signature,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
MinMosisVersion *string `json:"min_mosis_version,omitempty"`
|
||||
ReleaseNotes *string `json:"release_notes,omitempty"`
|
||||
Status VersionStatus `json:"status"`
|
||||
ReviewNotes *string `json:"review_notes,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### PublicApp
|
||||
|
||||
```go
|
||||
type PublicApp struct {
|
||||
PackageID string `json:"package_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
IconURL *string `json:"icon_url,omitempty"`
|
||||
AuthorName string `json:"author_name"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
DownloadCount int64 `json:"download_count"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### APIKey
|
||||
|
||||
```go
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"` // "mk_live_abc..."
|
||||
Permissions []string `json:"permissions"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### CrashReport
|
||||
|
||||
```go
|
||||
type CrashReport struct {
|
||||
ID string `json:"id"`
|
||||
AppID string `json:"app_id"`
|
||||
AppVersion string `json:"app_version"`
|
||||
CrashType string `json:"crash_type"`
|
||||
Message string `json:"message"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
Occurrences int `json:"occurrences"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid package_id format",
|
||||
"details": {
|
||||
"field": "package_id",
|
||||
"constraint": "Must match pattern: ^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
|------|-------------|-------------|
|
||||
| `UNAUTHORIZED` | 401 | Missing or invalid auth |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `VALIDATION_ERROR` | 400 | Invalid request body |
|
||||
| `CONFLICT` | 409 | Resource already exists |
|
||||
| `RATE_LIMITED` | 429 | Too many requests |
|
||||
| `INTERNAL_ERROR` | 500 | Server error |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Headers
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1704067200
|
||||
```
|
||||
|
||||
### Limits
|
||||
|
||||
| Endpoint Category | Limit | Window |
|
||||
|-------------------|-------|--------|
|
||||
| Auth | 10 | 1 minute |
|
||||
| Read | 1000 | 1 hour |
|
||||
| Write | 100 | 1 hour |
|
||||
| Upload | 10 | 1 hour |
|
||||
| Telemetry | 10000 | 1 hour |
|
||||
|
||||
---
|
||||
|
||||
## Pagination
|
||||
|
||||
### Request
|
||||
|
||||
```
|
||||
GET /v1/apps?page=2&limit=20
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"apps": [...],
|
||||
"pagination": {
|
||||
"page": 2,
|
||||
"limit": 20,
|
||||
"total": 45,
|
||||
"total_pages": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhooks (Future)
|
||||
|
||||
```yaml
|
||||
POST /v1/webhooks:
|
||||
summary: Register webhook
|
||||
body:
|
||||
url: string
|
||||
events: string[] # version.published, crash.new
|
||||
secret: string
|
||||
|
||||
Webhook Payload:
|
||||
headers:
|
||||
X-Mosis-Signature: sha256=xxx
|
||||
body:
|
||||
event: string
|
||||
data: object
|
||||
timestamp: string
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
Full OpenAPI 3.0 spec will be generated and hosted at:
|
||||
- `https://api.mosis.dev/v1/openapi.json`
|
||||
- `https://api.mosis.dev/v1/docs` (Swagger UI)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Structure (Go)
|
||||
|
||||
```
|
||||
cmd/
|
||||
└── portal/
|
||||
└── main.go # Entry point, wire dependencies
|
||||
|
||||
internal/
|
||||
├── api/
|
||||
│ ├── router.go # Chi router setup
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.go # JWT/API key validation
|
||||
│ │ ├── ratelimit.go # Token bucket rate limiter
|
||||
│ │ ├── logging.go # Request/response logging
|
||||
│ │ └── recovery.go # Panic recovery
|
||||
│ └── handlers/
|
||||
│ ├── auth.go # OAuth2 + token endpoints
|
||||
│ ├── apps.go # App CRUD
|
||||
│ ├── versions.go # Version upload/publish
|
||||
│ ├── store.go # Public store API
|
||||
│ ├── keys.go # API/signing keys
|
||||
│ └── telemetry.go # Event ingestion
|
||||
├── service/
|
||||
│ ├── app.go # Business logic
|
||||
│ ├── version.go
|
||||
│ ├── auth.go
|
||||
│ └── storage.go # File storage operations
|
||||
├── repository/
|
||||
│ ├── sqlite/ # SQLite implementations
|
||||
│ │ ├── app.go
|
||||
│ │ ├── version.go
|
||||
│ │ ├── developer.go
|
||||
│ │ └── migrations/ # SQL migrations
|
||||
│ └── interfaces.go # Repository interfaces
|
||||
└── domain/
|
||||
├── app.go # Domain types
|
||||
├── version.go
|
||||
└── developer.go
|
||||
|
||||
pkg/
|
||||
├── validator/ # Custom validation rules
|
||||
└── signing/ # Ed25519 operations
|
||||
```
|
||||
|
||||
### Chi Router Setup
|
||||
|
||||
```go
|
||||
func NewRouter(
|
||||
authHandler *handlers.AuthHandler,
|
||||
appHandler *handlers.AppHandler,
|
||||
storeHandler *handlers.StoreHandler,
|
||||
) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
|
||||
// API v1
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
// Public routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post("/auth/oauth/github", authHandler.GitHubOAuth)
|
||||
r.Get("/auth/oauth/github/callback", authHandler.GitHubCallback)
|
||||
r.Post("/auth/refresh", authHandler.Refresh)
|
||||
r.Get("/store/apps", storeHandler.ListApps)
|
||||
r.Get("/store/apps/{packageID}", storeHandler.GetApp)
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth)
|
||||
r.Get("/auth/me", authHandler.Me)
|
||||
r.Route("/apps", func(r chi.Router) {
|
||||
r.Get("/", appHandler.List)
|
||||
r.Post("/", appHandler.Create)
|
||||
r.Route("/{appID}", func(r chi.Router) {
|
||||
r.Get("/", appHandler.Get)
|
||||
r.Patch("/", appHandler.Update)
|
||||
r.Delete("/", appHandler.Delete)
|
||||
r.Route("/versions", func(r chi.Router) {
|
||||
r.Get("/", appHandler.ListVersions)
|
||||
r.Post("/", appHandler.CreateVersion)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
- [ ] OpenAPI specification
|
||||
- [ ] Authentication middleware
|
||||
- [ ] Rate limiting middleware
|
||||
- [ ] Auth endpoints
|
||||
- [ ] Apps CRUD endpoints
|
||||
- [ ] Versions endpoints with upload flow
|
||||
- [ ] Store public endpoints
|
||||
- [ ] API keys management
|
||||
- [ ] Signing keys management
|
||||
- [ ] Telemetry ingestion
|
||||
- [ ] Error handling
|
||||
- [ ] Request validation
|
||||
- [ ] Integration tests
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. ~~GraphQL alongside REST?~~ → REST only for simplicity
|
||||
2. WebSocket for real-time review status? → Consider for v1.1
|
||||
3. ~~Batch operations for bulk updates?~~ → Not needed for MVP
|
||||
4. ~~API versioning strategy (URL vs header)?~~ → URL prefix (/v1/)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [REST API Design Guidelines](https://github.com/microsoft/api-guidelines)
|
||||
- [OpenAPI 3.0 Specification](https://swagger.io/specification/)
|
||||
- [HTTP Status Codes](https://httpstatuses.com/)
|
||||
Reference in New Issue
Block a user