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:
|
||||
- `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
|
||||
|
||||
+142
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user