Skip to content

Commit

Permalink
Implement PATCH endpoints for relevant models (#599)
Browse files Browse the repository at this point in the history
This PR implements PATCH endpoints for all models in which updating via PUT is
supported -- applications,event_types, and endpoints.
  • Loading branch information
svix-daniel authored Aug 1, 2022
1 parent efce269 commit 9fbbfc4
Show file tree
Hide file tree
Showing 9 changed files with 1,233 additions and 9 deletions.
134 changes: 130 additions & 4 deletions server/svix-server/src/v1/endpoints/application.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: © 2022 Svix Authors
// SPDX-License-Identifier: MIT

use std::borrow::Cow;

use crate::{
core::{
security::{
Expand All @@ -12,8 +14,13 @@ use crate::{
db::models::application,
error::{HttpError, Result},
v1::utils::{
validate_no_control_characters, EmptyResponse, ListResponse, ModelIn, ModelOut, Pagination,
PaginationLimit, ValidatedJson, ValidatedQuery,
patch::{
patch_field_non_nullable, patch_field_nullable, UnrequiredField,
UnrequiredNullableField,
},
validate_no_control_characters, validate_no_control_characters_unrequired, EmptyResponse,
ListResponse, ModelIn, ModelOut, Pagination, PaginationLimit, ValidatedJson,
ValidatedQuery,
},
};
use axum::{
Expand All @@ -27,7 +34,7 @@ use sea_orm::{entity::prelude::*, ActiveValue::Set, QueryOrder};
use sea_orm::{ActiveModelTrait, DatabaseConnection, QuerySelect};
use serde::{Deserialize, Serialize};
use svix_server_derive::{ModelIn, ModelOut};
use validator::Validate;
use validator::{Validate, ValidationError};

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Validate, ModelIn)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -58,6 +65,82 @@ impl ModelIn for ApplicationIn {
}
}

#[derive(Deserialize, ModelIn, Serialize, Validate)]
#[serde(rename_all = "camelCase")]
pub struct ApplicationPatch {
#[serde(default, skip_serializing_if = "UnrequiredField::is_absent")]
#[validate(
custom = "validate_name_length_patch",
custom = "validate_no_control_characters_unrequired"
)]
pub name: UnrequiredField<String>,

#[serde(default, skip_serializing_if = "UnrequiredNullableField::is_absent")]
#[validate(custom = "validate_rate_limit_patch")]
pub rate_limit: UnrequiredNullableField<u16>,

#[serde(default, skip_serializing_if = "UnrequiredNullableField::is_absent")]
#[validate]
pub uid: UnrequiredNullableField<ApplicationUid>,
}

impl ModelIn for ApplicationPatch {
type ActiveModel = application::ActiveModel;

fn update_model(self, model: &mut Self::ActiveModel) {
let ApplicationPatch {
name,
rate_limit,
uid,
} = self;

// `model`'s version of `rate_limit` is an i32, while `self`'s is a u16.
let rate_limit_map = |x: u16| -> i32 { x.into() };

patch_field_non_nullable!(model, name);
patch_field_nullable!(model, rate_limit, rate_limit_map);
patch_field_nullable!(model, uid);
}
}

fn validate_name_length_patch(
name: &UnrequiredField<String>,
) -> std::result::Result<(), ValidationError> {
match name {
UnrequiredField::Absent => Ok(()),
UnrequiredField::Some(s) => {
if s.is_empty() {
let mut error = ValidationError::new("length");
error.message = Some(Cow::from(
"Application names must be at least one character",
));
Err(error)
} else {
Ok(())
}
}
}
}

fn validate_rate_limit_patch(
rate_limit: &UnrequiredNullableField<u16>,
) -> std::result::Result<(), ValidationError> {
match rate_limit {
UnrequiredNullableField::Absent | UnrequiredNullableField::None => Ok(()),
UnrequiredNullableField::Some(rate_limit) => {
if *rate_limit > 0 {
Ok(())
} else {
let mut error = ValidationError::new("range");
error.message = Some(Cow::from(
"Application rate limits must be at least 1 if set",
));
Err(error)
}
}
}
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut)]
#[serde(rename_all = "camelCase")]
pub struct ApplicationOut {
Expand Down Expand Up @@ -172,6 +255,21 @@ async fn update_application(
Ok(Json(ret.into()))
}

async fn patch_application(
Extension(ref db): Extension<DatabaseConnection>,
ValidatedJson(data): ValidatedJson<ApplicationPatch>,
AuthenticatedOrganizationWithApplication {
permissions: _,
app,
}: AuthenticatedOrganizationWithApplication,
) -> Result<Json<ApplicationOut>> {
let mut app: application::ActiveModel = app.into();
data.update_model(&mut app);

let ret = app.update(db).await?;
Ok(Json(ret.into()))
}

async fn delete_application(
Extension(ref db): Extension<DatabaseConnection>,
AuthenticatedOrganizationWithApplication {
Expand All @@ -193,13 +291,14 @@ pub fn router() -> Router {
"/app/:app_id/",
get(get_application)
.put(update_application)
.patch(patch_application)
.delete(delete_application),
)
}

#[cfg(test)]
mod tests {
use super::ApplicationIn;
use super::{ApplicationIn, ApplicationPatch};
use serde_json::json;
use validator::Validate;

Expand Down Expand Up @@ -235,4 +334,31 @@ mod tests {
.unwrap();
valid.validate().unwrap();
}

// FIXME: How to eliminate the repetition here?
#[test]
fn test_application_patch_validation() {
let invalid_1: ApplicationPatch =
serde_json::from_value(json!({ "name": APP_NAME_INVALID })).unwrap();
let invalid_2: ApplicationPatch = serde_json::from_value(json!({
"name": APP_NAME_VALID,
"rateLimit": RATE_LIMIT_INVALID }))
.unwrap();
let invalid_3: ApplicationPatch = serde_json::from_value(json!({
"name": APP_NAME_VALID,
"uid": UID_INVALID }))
.unwrap();

for a in [invalid_1, invalid_2, invalid_3] {
assert!(a.validate().is_err());
}

let valid: ApplicationPatch = serde_json::from_value(json!({
"name": APP_NAME_VALID,
"rateLimit": RATE_LIMIT_VALID,
"uid": UID_VALID,
}))
.unwrap();
valid.validate().unwrap();
}
}
44 changes: 43 additions & 1 deletion server/svix-server/src/v1/endpoints/endpoint/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use sea_orm::{entity::prelude::*, ActiveValue::Set, QueryOrder};
use sea_orm::{ActiveModelTrait, DatabaseConnection, QuerySelect};
use url::Url;

use super::{secrets::generate_secret, EndpointIn, EndpointOut};
use super::{secrets::generate_secret, EndpointIn, EndpointOut, EndpointPatch};
use crate::{
cfg::Configuration,
core::{
Expand All @@ -23,6 +23,7 @@ use crate::{
db::models::{endpoint, eventtype},
error::{HttpError, Result, ValidationErrorItem},
v1::utils::{
patch::{UnrequiredField, UnrequiredNullableField},
EmptyResponse, ListResponse, ModelIn, ModelOut, Pagination, PaginationLimit, ValidatedJson,
ValidatedQuery,
},
Expand Down Expand Up @@ -156,6 +157,47 @@ pub(super) async fn update_endpoint(
Ok(Json(ret.into()))
}

pub(super) async fn patch_endpoint(
Extension(ref db): Extension<DatabaseConnection>,
Extension(cfg): Extension<Configuration>,
Extension(op_webhooks): Extension<OperationalWebhookSender>,
Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
ValidatedJson(data): ValidatedJson<EndpointPatch>,
AuthenticatedApplication { permissions, app }: AuthenticatedApplication,
) -> Result<Json<EndpointOut>> {
let endp = endpoint::Entity::secure_find_by_id_or_uid(app.id.clone(), endp_id)
.one(db)
.await?
.ok_or_else(|| HttpError::not_found(None, None))?;

if let UnrequiredNullableField::Some(ref event_types_ids) = data.event_types_ids {
validate_event_types(db, event_types_ids, &permissions.org_id).await?;
}
if let UnrequiredField::Some(url) = &data.url {
validate_endpoint_url(url, cfg.endpoint_https_only)?;
}

let mut endp: endpoint::ActiveModel = endp.into();
data.update_model(&mut endp);

let ret = endp.update(db).await?;

let app_uid = app.uid;
op_webhooks
.send_operational_webhook(
&permissions.org_id,
OperationalWebhook::EndpointUpdated(EndpointEvent {
app_id: &ret.app_id,
app_uid: app_uid.as_ref(),
endpoint_id: &ret.id,
endpoint_uid: ret.uid.as_ref(),
}),
)
.await?;

Ok(Json(ret.into()))
}

pub(super) async fn delete_endpoint(
Extension(ref db): Extension<DatabaseConnection>,
Extension(op_webhooks): Extension<OperationalWebhookSender>,
Expand Down
Loading

0 comments on commit 9fbbfc4

Please sign in to comment.