Add patch to api

This commit is contained in:
Jon Staab
2026-02-13 16:08:24 -08:00
parent a4c196cc64
commit f0bfd27be6
4 changed files with 295 additions and 0 deletions
+1
View File
@@ -127,6 +127,7 @@ The API accepts JSON config objects and stores them as TOML files in the `CONFIG
Endpoints:
- `POST /relay/{id}` - Creates a new virtual relay config. Returns 201 on success, 409 if the id/schema/host already exists, 400 for invalid config.
- `PUT /relay/{id}` - Updates an existing virtual relay config. Returns 200 on success, 404 if the id doesn't exist, 409 if the new schema/host conflicts with another relay.
- `PATCH /relay/{id}` - Partially updates an existing virtual relay config by recursively merging the provided JSON. Returns 200 on success, 404 if the id doesn't exist, 409 if the new schema/host conflicts, 400 for invalid config. Use `null` values to remove fields.
- `DELETE /relay/{id}` - Deletes a virtual relay config. Returns 200 on success, 404 if the id doesn't exist.
## Scripts
Executable
BIN
View File
Binary file not shown.
+142
View File
@@ -110,6 +110,8 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
api.createRelay(w, r, id)
case http.MethodPut:
api.updateRelay(w, r, id)
case http.MethodPatch:
api.patchRelay(w, r, id)
case http.MethodDelete:
api.deleteRelay(w, r, id)
default:
@@ -270,6 +272,146 @@ func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id st
json.NewEncoder(w).Encode(map[string]string{"message": "relay updated successfully"})
}
// patchRelay partially updates an existing relay config by recursively merging changes
func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request, id string) {
configPath := filepath.Join(api.configDir, id+".toml")
// Check if file exists
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "relay not found"})
return
}
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to check config: %v", err)})
return
}
// Read existing config
var existingConfig RelayConfigJSON
if _, err := toml.DecodeFile(configPath, &existingConfig); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read existing config: %v", err)})
return
}
// Read and parse the patch
r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) // 1MB limit
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read body: %v", err)})
return
}
var patch map[string]interface{}
if err := json.Unmarshal(body, &patch); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid json: %v", err)})
return
}
// Convert existing config to map for merging
existingJSON, _ := json.Marshal(existingConfig)
var existingMap map[string]interface{}
json.Unmarshal(existingJSON, &existingMap)
// Recursively merge patch into existing
mergedMap := deepMerge(existingMap, patch)
// Convert back to RelayConfigJSON
mergedJSON, _ := json.Marshal(mergedMap)
var mergedConfig RelayConfigJSON
if err := json.Unmarshal(mergedJSON, &mergedConfig); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to process merged config: %v", err)})
return
}
// Validate required fields are still present
if mergedConfig.Host == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "host is required"})
return
}
if mergedConfig.Schema == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "schema is required"})
return
}
if mergedConfig.Secret == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "secret is required"})
return
}
// Validate the secret key
if _, err := nostr.SecretKeyFromHex(mergedConfig.Secret); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid secret key: %v", err)})
return
}
// Validate info.pubkey if provided
if mergedConfig.Info.Pubkey != "" {
if _, err := nostr.PubKeyFromHex(mergedConfig.Info.Pubkey); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid info.pubkey: %v", err)})
return
}
}
// Check for duplicate schema or host (excluding this config file)
if err := api.checkDuplicateSchemaOrHost(&mergedConfig, id+".toml"); err != nil {
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
// Convert to TOML and write
if err := api.writeConfigAsTOML(configPath, &mergedConfig); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to write config: %v", err)})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "relay patched successfully"})
}
// deepMerge recursively merges patch into base
func deepMerge(base, patch map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy all values from base
for k, v := range base {
result[k] = v
}
// Apply patches
for k, v := range patch {
if v == nil {
// Remove key if explicitly set to null
delete(result, k)
} else if patchMap, ok := v.(map[string]interface{}); ok {
// Recursively merge nested maps
if baseMap, ok := base[k].(map[string]interface{}); ok {
result[k] = deepMerge(baseMap, patchMap)
} else {
result[k] = v
}
} else {
// Replace value
result[k] = v
}
}
return result
}
// deleteRelay deletes a relay config file
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) {
configPath := filepath.Join(api.configDir, id+".toml")
+152
View File
@@ -398,6 +398,158 @@ func TestAPIHandler_UpdateRelay(t *testing.T) {
})
}
func TestAPIHandler_PatchRelay(t *testing.T) {
configDir := t.TempDir()
secretKey := nostr.Generate()
pubkey := secretKey.Public()
whitelist := pubkey.Hex()
api := NewAPIHandler(whitelist, configDir)
// Create initial relay with full config
initialConfig := map[string]interface{}{
"host": "relay.example.com",
"schema": "testrelay",
"secret": secretKey.Hex(),
"info": map[string]interface{}{
"name": "Original Name",
"icon": "https://example.com/original.png",
"pubkey": pubkey.Hex(),
"description": "Original description",
},
"policy": map[string]interface{}{
"public_join": false,
"strip_signatures": false,
},
"groups": map[string]interface{}{
"enabled": true,
"auto_join": false,
},
}
body, _ := json.Marshal(initialConfig)
req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/testrelay", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("failed to create initial relay: %d - %s", w.Code, w.Body.String())
}
t.Run("patch existing relay - update single field", func(t *testing.T) {
patch := map[string]interface{}{
"info": map[string]interface{}{
"name": "Patched Name",
},
}
body, _ := json.Marshal(patch)
req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
})
t.Run("patch existing relay - update nested fields", func(t *testing.T) {
patch := map[string]interface{}{
"info": map[string]interface{}{
"description": "Updated description",
"icon": "https://example.com/new-icon.png",
},
"policy": map[string]interface{}{
"public_join": true,
},
}
body, _ := json.Marshal(patch)
req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
})
t.Run("patch non-existent relay returns not found", func(t *testing.T) {
patch := map[string]interface{}{
"info": map[string]interface{}{
"name": "New Name",
},
}
body, _ := json.Marshal(patch)
req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/nonexistent", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
})
t.Run("patch with duplicate schema returns conflict", func(t *testing.T) {
// Create another relay first
otherConfig := map[string]interface{}{
"host": "other.example.com",
"schema": "anotherrelay",
"secret": secretKey.Hex(),
}
body, _ := json.Marshal(otherConfig)
req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/anotherrelay", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("failed to create other relay: %d", w.Code)
}
// Try to patch first relay with second relay's schema
patch := map[string]interface{}{
"schema": "anotherrelay", // Duplicate
}
body, _ = json.Marshal(patch)
req = createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", secretKey, body)
w = httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code)
}
})
t.Run("patch with invalid secret key", func(t *testing.T) {
patch := map[string]interface{}{
"secret": "not-a-valid-hex-key",
}
body, _ := json.Marshal(patch)
req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("patch removing required field fails", func(t *testing.T) {
patch := map[string]interface{}{
"host": nil,
}
body, _ := json.Marshal(patch)
req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", secretKey, body)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
}
func TestAPIHandler_DeleteRelay(t *testing.T) {
configDir := t.TempDir()