Add patch to api
This commit is contained in:
@@ -127,6 +127,7 @@ The API accepts JSON config objects and stores them as TOML files in the `CONFIG
|
|||||||
Endpoints:
|
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.
|
- `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.
|
- `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.
|
- `DELETE /relay/{id}` - Deletes a virtual relay config. Returns 200 on success, 404 if the id doesn't exist.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|||||||
+142
@@ -110,6 +110,8 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
api.createRelay(w, r, id)
|
api.createRelay(w, r, id)
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
api.updateRelay(w, r, id)
|
api.updateRelay(w, r, id)
|
||||||
|
case http.MethodPatch:
|
||||||
|
api.patchRelay(w, r, id)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
api.deleteRelay(w, r, id)
|
api.deleteRelay(w, r, id)
|
||||||
default:
|
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"})
|
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
|
// deleteRelay deletes a relay config file
|
||||||
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) {
|
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
configPath := filepath.Join(api.configDir, id+".toml")
|
configPath := filepath.Join(api.configDir, id+".toml")
|
||||||
|
|||||||
@@ -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) {
|
func TestAPIHandler_DeleteRelay(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user