Skip to content

Commit

Permalink
Refactor BNA API
Browse files Browse the repository at this point in the history
Refactors the BNA API to match the Zalando best practices as well as our
OpenAPI specification.

- Fixes ACCEPT_CONTENT headers in `APIError`
- Adds convenience functions associated to `APIErrors`
- Expands the BNARequestExt Trait
- Fixes the CityPost wrapper
- Fixes the logic of the POST /cities endpoint and adds some extra
  validations
-

Signed-off-by: Rémy Greinhofer <[email protected]>
  • Loading branch information
rgreinho committed Aug 8, 2024
1 parent 0f703fb commit 416fc45
Show file tree
Hide file tree
Showing 11 changed files with 291 additions and 147 deletions.
23 changes: 22 additions & 1 deletion effortless/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use lambda_http::{http::StatusCode, Body, Response};
use lambda_http::{http::header, http::StatusCode, Body, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_with::skip_serializing_none;
Expand Down Expand Up @@ -133,6 +133,26 @@ impl APIErrors {
errors: errors.to_vec(),
}
}

/// Creates an empty `APIErrors`.
pub fn empty() -> Self {
Self { errors: vec![] }
}

/// Adds an `APIError`.
pub fn add(mut self, value: APIError) {
self.errors.push(value);
}

/// Extends with an existing `APIErrors`.
pub fn extend(&mut self, value: APIErrors) {
self.errors.extend(value.errors);
}

/// Returns True if there is no error.
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
}

impl From<APIError> for APIErrors {
Expand All @@ -156,6 +176,7 @@ impl From<APIErrors> for Response<Body> {
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json")
.body(json!(value).to_string().into())
.unwrap()
}
Expand Down
7 changes: 7 additions & 0 deletions effortless/src/fragment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ pub trait BnaRequestExt {
/// If there is no request ID or the event is not coming from an ApiGatewayV2, the
/// function returns None.
fn apigw_request_id(&self) -> Option<String>;

/// Returns true if there are path parameters available.
fn has_path_parameters(&self) -> bool;
}

impl<B> BnaRequestExt for http::Request<B> {
Expand Down Expand Up @@ -183,6 +186,10 @@ impl<B> BnaRequestExt for http::Request<B> {
_ => None,
}
}

fn has_path_parameters(&self) -> bool {
!self.path_parameters().is_empty()
}
}

#[cfg(test)]
Expand Down
3 changes: 2 additions & 1 deletion entity/src/wrappers/city.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub struct CityPost {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub name: String,
pub region: Option<String>,
pub state: String,
pub state_abbrev: Option<String>,
pub speed_limit: Option<i32>,
Expand All @@ -21,7 +22,7 @@ impl IntoActiveModel<city::ActiveModel> for CityPost {
latitude: ActiveValue::Set(self.latitude),
longitude: ActiveValue::Set(self.longitude),
name: ActiveValue::Set(self.name),
region: ActiveValue::NotSet,
region: ActiveValue::set(self.region),
state: ActiveValue::Set(self.state),
state_abbrev: ActiveValue::Set(self.state_abbrev),
speed_limit: ActiveValue::Set(self.speed_limit),
Expand Down
57 changes: 25 additions & 32 deletions lambdas/src/cities/get-cities-bnas.rs
Original file line number Diff line number Diff line change
@@ -1,54 +1,47 @@
use dotenv::dotenv;
use effortless::api::{entry_not_found, missing_parameter, parse_path_parameter};
use effortless::api::entry_not_found;
use entity::{city, summary};
use lambda_http::{run, service_fn, Body, Error, Request, Response};
use lambdas::{build_paginated_response, database_connect, pagination_parameters};
use lambdas::{
build_paginated_response,
cities::{extract_path_parameters, PathParameters},
database_connect, pagination_parameters,
};
use sea_orm::{EntityTrait, PaginatorTrait};
use serde_json::json;
use tracing::info;

async fn function_handler(event: Request) -> Result<Response<Body>, Error> {
dotenv().ok();

// Set the database connection.
let db = database_connect(Some("DATABASE_URL_SECRET_ID")).await?;
// Extract the path parameters.
let params: PathParameters = match extract_path_parameters(&event) {
Ok(p) => p,
Err(e) => return Ok(e.into()),
};

// Retrieve pagination parameters if any.
let (page_size, page) = match pagination_parameters(&event) {
Ok((page_size, page)) => (page_size, page),
Err(e) => return Ok(e),
};

let country = match parse_path_parameter::<String>(&event, "country") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
dbg!(&country);
let region = match parse_path_parameter::<String>(&event, "region") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
let name = match parse_path_parameter::<String>(&event, "name") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
// Set the database connection.
let db = database_connect(Some("DATABASE_URL_SECRET_ID")).await?;

if country.is_some() && region.is_some() && name.is_some() {
let select = city::Entity::find_by_id((country.unwrap(), region.unwrap(), name.unwrap()))
.find_also_related(summary::Entity);
let model = select
.clone()
.paginate(&db, page_size)
.fetch_page(page - 1)
.await?;
if model.is_empty() {
return Ok(entry_not_found(&event).into());
}
let total_items = select.count(&db).await?;
build_paginated_response(json!(model), total_items, page, page_size, &event)
} else {
Ok(missing_parameter(&event, "country or region or name").into())
// Retrieve the city and associated BNA summary(ies).
let select = city::Entity::find_by_id((params.country, params.region, params.name))
.find_also_related(summary::Entity);
let model = select
.clone()
.paginate(&db, page_size)
.fetch_page(page - 1)
.await?;
if model.is_empty() {
return Ok(entry_not_found(&event).into());
}
let total_items = select.count(&db).await?;
build_paginated_response(json!(model), total_items, page, page_size, &event)
}

#[tokio::main]
Expand Down
56 changes: 25 additions & 31 deletions lambdas/src/cities/get-cities-census.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use dotenv::dotenv;
use effortless::api::{entry_not_found, missing_parameter, parse_path_parameter};
use effortless::api::entry_not_found;
use entity::{census, city};
use lambda_http::{run, service_fn, Body, Error, Request, Response};
use lambdas::{build_paginated_response, database_connect, pagination_parameters};
use lambdas::{
build_paginated_response,
cities::{extract_path_parameters, PathParameters},
database_connect, pagination_parameters,
};
use sea_orm::{EntityTrait, PaginatorTrait};
use serde_json::json;
use tracing::info;
Expand All @@ -26,44 +30,34 @@ async fn main() -> Result<(), Error> {
async fn function_handler(event: Request) -> Result<Response<Body>, Error> {
dotenv().ok();

// Set the database connection.
let db = database_connect(Some("DATABASE_URL_SECRET_ID")).await?;
// Extract the path parameters.
let params: PathParameters = match extract_path_parameters(&event) {
Ok(p) => p,
Err(e) => return Ok(e.into()),
};

// Retrieve pagination parameters if any.
let (page_size, page) = match pagination_parameters(&event) {
Ok((page_size, page)) => (page_size, page),
Err(e) => return Ok(e),
};

let country = match parse_path_parameter::<String>(&event, "country") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
let region = match parse_path_parameter::<String>(&event, "region") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
let name = match parse_path_parameter::<String>(&event, "name") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
// Set the database connection.
let db = database_connect(Some("DATABASE_URL_SECRET_ID")).await?;

if country.is_some() && region.is_some() && name.is_some() {
let select = city::Entity::find_by_id((country.unwrap(), region.unwrap(), name.unwrap()))
.find_also_related(census::Entity);
let model = select
.clone()
.paginate(&db, page_size)
.fetch_page(page - 1)
.await?;
if model.is_empty() {
return Ok(entry_not_found(&event).into());
}
let total_items = select.count(&db).await?;
build_paginated_response(json!(model), total_items, page, page_size, &event)
} else {
Ok(missing_parameter(&event, "country or region or name").into())
// Retrieve the city and associated census(es).
let select = city::Entity::find_by_id((params.country, params.region, params.name))
.find_also_related(census::Entity);
let model = select
.clone()
.paginate(&db, page_size)
.fetch_page(page - 1)
.await?;
if model.is_empty() {
return Ok(entry_not_found(&event).into());
}
let total_items = select.count(&db).await?;
build_paginated_response(json!(model), total_items, page, page_size, &event)
}

// #[cfg(test)]
Expand Down
113 changes: 61 additions & 52 deletions lambdas/src/cities/get-cities.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use dotenv::dotenv;
use effortless::api::{entry_not_found, parse_path_parameter};
use effortless::{api::entry_not_found, fragment::BnaRequestExt};
use entity::city;
use lambda_http::{run, service_fn, Body, Error, IntoResponse, Request, Response};
use lambdas::{build_paginated_response, database_connect, pagination_parameters};
use lambdas::{
build_paginated_response,
cities::{extract_path_parameters, PathParameters},
database_connect, pagination_parameters_2,
};
use sea_orm::{EntityTrait, PaginatorTrait};
use serde_json::json;
use tracing::info;
Expand All @@ -13,43 +17,43 @@ async fn function_handler(event: Request) -> Result<Response<Body>, Error> {
// Set the database connection.
let db = database_connect(Some("DATABASE_URL_SECRET_ID")).await?;

// Retrieve pagination parameters if any.
let (page_size, page) = match pagination_parameters(&event) {
Ok((page_size, page)) => (page_size, page),
Err(e) => return Ok(e),
};

let country = match parse_path_parameter::<String>(&event, "country") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
let region = match parse_path_parameter::<String>(&event, "region") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
let name = match parse_path_parameter::<String>(&event, "name") {
Ok(value) => value,
Err(e) => return Ok(e.into()),
};
// With params.
if event.has_path_parameters() {
let params: PathParameters = match extract_path_parameters(&event) {
Ok(p) => p,
Err(e) => return Ok(e.into()),
};

if country.is_some() && region.is_some() && name.is_some() {
let select = city::Entity::find_by_id((country.unwrap(), region.unwrap(), name.unwrap()));
let select = city::Entity::find_by_id((params.country, params.region, params.name));
let model = select.one(&db).await?;
let res: Response<Body> = match model {
Some(model) => json!(model).into_response().await,
None => entry_not_found(&event).into(),
};
Ok(res)
} else {
let select = city::Entity::find();
let body = select
.clone()
.paginate(&db, page_size)
.fetch_page(page - 1)
.await?;
let total_items = select.count(&db).await?;
build_paginated_response(json!(body), total_items, page, page_size, &event)
return Ok(res);
}

// Retrieve pagination parameters if any.
let pagination = match pagination_parameters_2(&event) {
Ok(p) => p,
Err(e) => return Ok(e),
};

// Without params.
let select = city::Entity::find();
let body = select
.clone()
.paginate(&db, pagination.page_size)
.fetch_page(pagination.page - 1)
.await?;
let total_items = select.count(&db).await?;
build_paginated_response(
json!(body),
total_items,
pagination.page,
pagination.page_size,
&event,
)
}

#[tokio::main]
Expand All @@ -70,25 +74,30 @@ async fn main() -> Result<(), Error> {

#[cfg(test)]
mod tests {
// use super::*;
// use lambda_http::{http, RequestExt};
// use std::collections::HashMap;
use super::*;
use lambda_http::{http, RequestExt};
use std::collections::HashMap;

// #[tokio::test]
// async fn test_handler_opportunity() {
// let event = http::Request::builder()
// .header(http::header::CONTENT_TYPE, "application/json")
// .body(Body::Empty)
// .expect("failed to build request")
// .with_path_parameters(HashMap::from([
// ("country".to_string(), "United%20States".to_string()),
// ("region".to_string(), "Texas".to_string()),
// ("name".to_string(), "Austin".to_string()),
// ]))
// .with_request_context(lambda_http::request::RequestContext::ApiGatewayV2(
// lambda_http::aws_lambda_events::apigw::ApiGatewayV2httpRequestContext::default(),
// ));
// let r = function_handler(event).await.unwrap();
// dbg!(r);
// }
#[test]
fn test_extract_path_parameters() {
let country: String = String::from("United States");
let region: String = String::from("Texas");
let name: String = String::from("Austin");
let event = http::Request::builder()
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::Empty)
.expect("failed to build request")
.with_path_parameters(HashMap::from([
("country".to_string(), country.clone()),
("region".to_string(), region.clone()),
("name".to_string(), name.clone()),
]))
.with_request_context(lambda_http::request::RequestContext::ApiGatewayV2(
lambda_http::aws_lambda_events::apigw::ApiGatewayV2httpRequestContext::default(),
));
let r = extract_path_parameters(&event).unwrap();
assert_eq!(r.country, country);
assert_eq!(r.region, region);
assert_eq!(r.name, name);
}
}
Loading

0 comments on commit 416fc45

Please sign in to comment.