Skip to content

Commit 7d98f68

Browse files
committed
server: Add support for creating application in cmg
1 parent 362fe86 commit 7d98f68

File tree

6 files changed

+242
-16
lines changed

6 files changed

+242
-16
lines changed

server/openapi.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,6 +1931,11 @@
19311931
},
19321932
"MessageIn": {
19331933
"properties": {
1934+
"application": {
1935+
"$ref": "#/components/schemas/ApplicationIn",
1936+
"description": "Optionally creates a new application alongside the message.\n\nIf the application id or uid that is used in the path already exists, this argument is ignored.",
1937+
"nullable": true
1938+
},
19341939
"channels": {
19351940
"description": "List of free-form identifiers that endpoints can filter by",
19361941
"example": [
@@ -5291,6 +5296,19 @@
52915296
},
52925297
"style": "form"
52935298
},
5299+
{
5300+
"in": "path",
5301+
"name": "app_id",
5302+
"required": true,
5303+
"schema": {
5304+
"example": "unique-app-identifier",
5305+
"maxLength": 256,
5306+
"minLength": 1,
5307+
"pattern": "^[a-zA-Z0-9\\-_.]+$",
5308+
"type": "string"
5309+
},
5310+
"style": "simple"
5311+
},
52945312
{
52955313
"description": "The request's idempotency key",
52965314
"in": "header",

server/svix-server/src/v1/endpoints/endpoint/mod.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ use crate::{
3131
cryptography::Encryption,
3232
permissions,
3333
types::{
34-
metadata::Metadata, EndpointHeaders, EndpointHeadersPatch, EndpointId, EndpointSecret,
35-
EndpointSecretInternal, EndpointUid, EventChannelSet, EventTypeName, EventTypeNameSet,
36-
MessageStatus,
34+
metadata::Metadata, ApplicationIdOrUid, EndpointHeaders, EndpointHeadersPatch,
35+
EndpointId, EndpointSecret, EndpointSecretInternal, EndpointUid, EventChannelSet,
36+
EventTypeName, EventTypeNameSet, MessageStatus,
3737
},
3838
},
3939
db::models::{
@@ -802,10 +802,20 @@ async fn send_example(
802802
uid: None,
803803
payload_retention_period: 90,
804804
extra_params: None,
805+
application: None,
805806
};
806807

807-
let create_message =
808-
create_message_inner(db, queue_tx, cache, false, Some(endpoint.id), msg_in, app).await?;
808+
let create_message = create_message_inner(
809+
db,
810+
queue_tx,
811+
cache,
812+
false,
813+
Some(endpoint.id),
814+
msg_in,
815+
app.org_id,
816+
ApplicationIdOrUid(app.id.0),
817+
)
818+
.await?;
809819

810820
Ok(Json(create_message))
811821
}

server/svix-server/src/v1/endpoints/message.rs

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,21 @@ use crate::{
2525
message_app::CreateMessageApp,
2626
permissions,
2727
types::{
28-
EndpointId, EventChannel, EventChannelSet, EventTypeName, EventTypeNameSet,
29-
MessageAttemptTriggerType, MessageId, MessageUid,
28+
ApplicationIdOrUid, EndpointId, EventChannel, EventChannelSet, EventTypeName,
29+
EventTypeNameSet, MessageAttemptTriggerType, MessageId, MessageUid, OrganizationId,
3030
},
3131
},
3232
db::models::{application, message, messagecontent},
33-
error::{http_error_on_conflict, Error, HttpError, Result},
33+
error::{http_error_on_conflict, Error, HttpError, Result, ValidationErrorItem},
3434
queue::{MessageTaskBatch, TaskQueueProducer},
35-
v1::utils::{
36-
filter_and_paginate_time_limited, openapi_tag, validation_error, ApplicationMsgPath,
37-
EventTypesQueryParams, JsonStatus, ListResponse, ModelIn, ModelOut, PaginationDescending,
38-
PaginationLimit, ReversibleIterator, ValidatedJson, ValidatedQuery,
35+
v1::{
36+
endpoints::application::{create_app_from_app_in, ApplicationIn},
37+
utils::{
38+
filter_and_paginate_time_limited, openapi_tag, validation_error, validation_errors,
39+
ApplicationMsgPath, ApplicationPath, EventTypesQueryParams, JsonStatus, ListResponse,
40+
ModelIn, ModelOut, PaginationDescending, PaginationLimit, ReversibleIterator,
41+
ValidatedJson, ValidatedQuery,
42+
},
3943
},
4044
AppState,
4145
};
@@ -127,6 +131,14 @@ pub struct MessageIn {
127131
#[serde(rename = "transformationsParams")]
128132
#[schemars(skip)]
129133
pub extra_params: Option<MessageInExtraParams>,
134+
135+
/// Optionally creates a new application alongside the message.
136+
///
137+
/// If the application id or uid that is used in the path already exists,
138+
/// this argument is ignored.
139+
#[validate]
140+
#[serde(skip_serializing_if = "Option::is_none")]
141+
pub application: Option<ApplicationIn>,
130142
}
131143

132144
impl MessageIn {
@@ -335,23 +347,64 @@ async fn create_message(
335347
ValidatedQuery(CreateMessageQueryParams { with_content }): ValidatedQuery<
336348
CreateMessageQueryParams,
337349
>,
338-
permissions::OrganizationWithApplication { app }: permissions::OrganizationWithApplication,
350+
Path(ApplicationPath { app_id }): Path<ApplicationPath>,
351+
permissions::Organization { org_id }: permissions::Organization,
339352
ValidatedJson(data): ValidatedJson<MessageIn>,
340353
) -> Result<JsonStatus<202, MessageOut>> {
341354
Ok(JsonStatus(
342-
create_message_inner(db, queue_tx, cache, with_content, None, data, app).await?,
355+
create_message_inner(
356+
db,
357+
queue_tx,
358+
cache,
359+
with_content,
360+
None,
361+
data,
362+
org_id,
363+
app_id,
364+
)
365+
.await?,
343366
))
344367
}
345368

369+
#[allow(clippy::too_many_arguments)]
346370
pub(crate) async fn create_message_inner(
347371
db: &DatabaseConnection,
348372
queue_tx: TaskQueueProducer,
349373
cache: Cache,
350374
with_content: bool,
351375
force_endpoint: Option<EndpointId>,
352376
data: MessageIn,
353-
app: application::Model,
377+
org_id: OrganizationId,
378+
app_id: ApplicationIdOrUid,
354379
) -> Result<MessageOut> {
380+
app_id.validate().map_err(|e| {
381+
HttpError::unprocessable_entity(validation_errors(
382+
vec!["path".to_owned(), "app_id_or_uid".to_owned()],
383+
e,
384+
))
385+
})?;
386+
387+
let app_from_path_app_id =
388+
application::Entity::secure_find_by_id_or_uid(org_id.clone(), app_id.to_owned())
389+
.one(db)
390+
.await?;
391+
392+
let app = match (&data.application, app_from_path_app_id) {
393+
(None, None) => {
394+
return Err(
395+
HttpError::not_found(None, Some("Application not found".to_string())).into(),
396+
);
397+
}
398+
399+
(_, Some(app_from_path_param)) => app_from_path_param,
400+
(Some(cmg_app), None) => {
401+
validate_create_app_uid(&app_id, cmg_app)?;
402+
let (app, _metadata) = create_app_from_app_in(db, cmg_app.to_owned(), org_id).await?;
403+
404+
app
405+
}
406+
};
407+
355408
let create_message_app = CreateMessageApp::layered_fetch(
356409
&cache,
357410
db,
@@ -407,6 +460,37 @@ pub(crate) async fn create_message_inner(
407460
Ok(msg_out)
408461
}
409462

463+
fn validate_create_app_uid(app_id_or_uid: &ApplicationIdOrUid, data: &ApplicationIn) -> Result<()> {
464+
// If implicit app creation is requested then the UID must be set
465+
// in the request body, and it must match the UID given in the path
466+
if let Some(uid) = &data.uid {
467+
if uid.0 != app_id_or_uid.0 {
468+
return Err(HttpError::unprocessable_entity(vec![ValidationErrorItem {
469+
loc: vec![
470+
"body".to_string(),
471+
"application".to_string(),
472+
"uid".to_string(),
473+
],
474+
msg: "Application UID in the path and body must match".to_string(),
475+
ty: "application_uid_mismatch".to_string(),
476+
}])
477+
.into());
478+
}
479+
} else {
480+
return Err(HttpError::unprocessable_entity(vec![ValidationErrorItem {
481+
loc: vec![
482+
"body".to_string(),
483+
"application".to_string(),
484+
"uid".to_string(),
485+
],
486+
msg: "Application UID not set in body".to_string(),
487+
ty: "application_uid_missing".to_string(),
488+
}])
489+
.into());
490+
}
491+
Ok(())
492+
}
493+
410494
#[derive(Debug, Deserialize, Validate, JsonSchema)]
411495
pub struct GetMessageQueryParams {
412496
/// When `true` message payloads are included in the response

server/svix-server/src/v1/utils/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ pub fn validation_error(code: Option<&'static str>, msg: Option<&'static str>) -
500500

501501
/// Recursively searches a [`validator::ValidationErrors`] tree into a linear list of errors to be
502502
/// sent to the user
503-
fn validation_errors(
503+
pub fn validation_errors(
504504
acc_path: Vec<String>,
505505
err: validator::ValidationErrors,
506506
) -> Vec<ValidationErrorItem> {

server/svix-server/tests/it/e2e_message.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: MIT
33

44
use chrono::{Duration, Utc};
5+
use rand::distributions::DistString;
56
use reqwest::StatusCode;
67
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, QueryFilter};
78
use serde::de::IgnoredAny;
@@ -18,6 +19,10 @@ use svix_server::{
1819
},
1920
};
2021

22+
fn rand_str(len: usize) -> String {
23+
rand::distributions::Alphanumeric.sample_string(&mut rand::thread_rng(), len)
24+
}
25+
2126
use crate::utils::{
2227
common_calls::{create_test_app, create_test_endpoint, create_test_msg_with, message_in},
2328
run_with_retries, start_svix_server, TestReceiver,
@@ -603,3 +608,111 @@ async fn test_raw_payload() {
603608
let rec_body = receiver.data_recv.recv().await;
604609
assert_eq!(msg_payload.to_string(), rec_body.unwrap().to_string());
605610
}
611+
612+
#[tokio::test]
613+
async fn test_create_message_with_application() {
614+
let (client, _jh) = start_svix_server().await;
615+
616+
let app_uid = format!("app-created-in-cmg-{}", rand_str(15));
617+
618+
// cmg without the application field fails
619+
let _: IgnoredAny = client
620+
.post(
621+
&format!("api/v1/app/{app_uid}/msg/"),
622+
json!({
623+
"eventType": "test.event",
624+
"payload": { "test": "value" }
625+
}),
626+
StatusCode::NOT_FOUND,
627+
)
628+
.await
629+
.unwrap();
630+
631+
// cmg with application
632+
let _: IgnoredAny = client
633+
.post(
634+
&format!("api/v1/app/{app_uid}/msg/"),
635+
json!({
636+
"eventType": "test.event",
637+
"payload": { "test": "value1" },
638+
"application": {
639+
"name": "Test App Created With Message",
640+
"uid": app_uid,
641+
}
642+
}),
643+
StatusCode::ACCEPTED,
644+
)
645+
.await
646+
.unwrap();
647+
648+
// app was created
649+
let app: serde_json::Value = client
650+
.get(&format!("api/v1/app/{app_uid}/"), StatusCode::OK)
651+
.await
652+
.unwrap();
653+
654+
assert_eq!(app["uid"], app_uid);
655+
assert_eq!(app["name"], "Test App Created With Message");
656+
657+
// Create another message to the now-existing app with the application field
658+
// The application field should be ignored since the app already exists
659+
let _: IgnoredAny = client
660+
.post(
661+
&format!("api/v1/app/{app_uid}/msg/"),
662+
json!({
663+
"eventType": "test.event",
664+
"payload": { "test": "value2" },
665+
"application": {
666+
"name": "Updated name will be ignored",
667+
"uid": app_uid,
668+
}
669+
}),
670+
StatusCode::ACCEPTED,
671+
)
672+
.await
673+
.unwrap();
674+
675+
// Verify the app name didn't change
676+
let app_after: serde_json::Value = client
677+
.get(&format!("api/v1/app/{app_uid}/"), StatusCode::OK)
678+
.await
679+
.unwrap();
680+
681+
assert_eq!(app_after["name"], "Test App Created With Message");
682+
683+
// UID in path must match UID in body
684+
let _: IgnoredAny = client
685+
.post(
686+
"api/v1/app/different-uid/msg/",
687+
json!({
688+
"eventType": "test.event",
689+
"payload": { "test": "value" },
690+
"payloadRetentionPeriod": 5,
691+
"application": {
692+
"name": "Test App",
693+
"uid": app_uid, // This doesn't match the path
694+
}
695+
}),
696+
StatusCode::UNPROCESSABLE_ENTITY,
697+
)
698+
.await
699+
.unwrap();
700+
701+
// UID must be set in body when creating
702+
let _: IgnoredAny = client
703+
.post(
704+
"api/v1/app/new-app-uid/msg/",
705+
json!({
706+
"eventType": "test.event",
707+
"payload": { "test": "value" },
708+
"payloadRetentionPeriod": 5,
709+
"application": {
710+
"name": "Test App Without UID",
711+
// Missing uid field
712+
}
713+
}),
714+
StatusCode::UNPROCESSABLE_ENTITY,
715+
)
716+
.await
717+
.unwrap();
718+
}

server/svix-server/tests/it/utils/common_calls.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ pub fn message_in<T: Serialize>(event_type: &str, payload: T) -> Result<MessageI
124124
channels: None,
125125
uid: None,
126126
extra_params: None,
127+
application: None,
127128
})
128129
}
129130

0 commit comments

Comments
 (0)