Add initial implementation of a Server example (#1222)

We are adding this example service in the aws-smithy-http-server folder to showcase the SSDK and to be able soon to run benchmarks against this example.

This change includes a new model for the Pokémon Service and all the necessary infrastructure to code generate the client and server SKDs and make them available to the runtime implementation.

A basic README has also been added with instructions on how to build, run and test the service.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
This commit is contained in:
Matteo Bigoi 2022-02-25 14:19:05 +00:00 committed by GitHub
parent d4c23e3fd2
commit bbe82cd283
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 566 additions and 10 deletions

View File

@ -37,17 +37,20 @@ val allCodegenTests = listOf(
CodegenTest("aws.protocoltests.restjson#RestJson", "rest_json"),
CodegenTest("aws.protocoltests.restjson.validation#RestJsonValidation", "rest_json_validation"),
CodegenTest("com.amazonaws.ebs#Ebs", "ebs"),
CodegenTest("com.amazonaws.s3#AmazonS3", "s3")
CodegenTest("com.amazonaws.s3#AmazonS3", "s3"),
CodegenTest("com.aws.example#PokemonService", "pokemon_service_sdk")
)
task("generateSmithyBuild") {
description = "generate smithy-build.json"
doFirst {
projectDir.resolve("smithy-build.json")
.writeText(generateSmithyBuild(
rootProject.projectDir.absolutePath,
pluginName,
codegenTests(properties, allCodegenTests))
.writeText(
generateSmithyBuild(
rootProject.projectDir.absolutePath,
pluginName,
codegenTests(properties, allCodegenTests)
)
)
}
}

View File

@ -0,0 +1,109 @@
$version: "1.0"
namespace com.aws.example
use aws.protocols#restJson1
/// The Pokémon Service allows you to retrieve information about Pokémon species.
@title("Pokémon Service")
@restJson1
service PokemonService {
version: "2021-12-01",
resources: [PokemonSpecies],
operations: [GetServerStatistics],
}
/// A Pokémon species forms the basis for at least one Pokémon.
@title("Pokémon Species")
resource PokemonSpecies {
identifiers: {
name: String
},
read: GetPokemonSpecies,
}
/// Retrieve information about a Pokémon species.
@readonly
@http(uri: "/pokemon-species/{name}", method: "GET")
operation GetPokemonSpecies {
input: GetPokemonSpeciesInput,
output: GetPokemonSpeciesOutput,
errors: [ResourceNotFoundException],
}
@input
structure GetPokemonSpeciesInput {
@required
@httpLabel
name: String
}
@output
structure GetPokemonSpeciesOutput {
/// The name for this resource.
@required
name: String,
/// A list of flavor text entries for this Pokémon species.
@required
flavorTextEntries: FlavorTextEntries
}
/// Retrieve HTTP server statistiscs, such as calls count.
@readonly
@http(uri: "/stats", method: "GET")
operation GetServerStatistics {
input: GetServerStatisticsInput,
output: GetServerStatisticsOutput,
}
@input
structure GetServerStatisticsInput { }
@output
structure GetServerStatisticsOutput {
/// The number of calls executed by the server.
@required
calls_count: Long,
}
list FlavorTextEntries {
member: FlavorText
}
structure FlavorText {
/// The localized flavor text for an API resource in a specific language.
@required
flavorText: String,
/// The language this name is in.
@required
language: Language,
}
/// Supported languages for FlavorText entries.
@enum([
{
name: "ENGLISH",
value: "en",
documentation: "American English.",
},
{
name: "SPANISH",
value: "es",
documentation: "Español.",
},
{
name: "ITALIAN",
value: "it",
documentation: "Italiano.",
},
])
string Language
@error("client")
@httpError(404)
structure ResourceNotFoundException {
@required
message: String,
}

View File

@ -83,17 +83,20 @@ val allCodegenTests = listOf(
"""
, "codegen": { "renameErrors": false }
""".trimIndent()
)
),
CodegenTest("com.aws.example#PokemonService", "pokemon_service_client")
)
task("generateSmithyBuild") {
description = "generate smithy-build.json"
doFirst {
projectDir.resolve("smithy-build.json")
.writeText(generateSmithyBuild(
rootProject.projectDir.absolutePath,
pluginName,
codegenTests(properties, allCodegenTests))
.writeText(
generateSmithyBuild(
rootProject.projectDir.absolutePath,
pluginName,
codegenTests(properties, allCodegenTests)
)
)
}
}

View File

@ -0,0 +1 @@
../../codegen-server-test/model/pokemon.smithy

View File

@ -0,0 +1,2 @@
pokemon_service_client/
pokemon_service_sdk/

View File

@ -0,0 +1,7 @@
# Without this configuration, the workspace will be read from `rust-runtime`, causing the build to fail.
[workspace]
members = [
"pokemon_service",
"pokemon_service_sdk",
"pokemon_service_client"
]

View File

@ -0,0 +1,27 @@
SRC_DIR := $(shell git rev-parse --show-toplevel)
CUR_DIR := $(shell pwd)
GRADLE := $(SRC_DIR)/gradlew
SERVER_SDK_DST := $(CUR_DIR)/pokemon_service_sdk
CLIENT_SDK_DST := $(CUR_DIR)/pokemon_service_client
SERVER_SDK_SRC := $(SRC_DIR)/codegen-server-test/build/smithyprojections/codegen-server-test/pokemon_service_sdk/rust-server-codegen
CLIENT_SDK_SRC := $(SRC_DIR)/codegen-test/build/smithyprojections/codegen-test/pokemon_service_client/rust-codegen
all: build
codegen:
$(GRADLE) --project-dir $(SRC_DIR) :codegen-test:assemble
$(GRADLE) --project-dir $(SRC_DIR) :codegen-server-test:assemble
mkdir -p $(SERVER_SDK_DST) $(CLIENT_SDK_DST)
cp -av $(SERVER_SDK_SRC)/* $(SERVER_SDK_DST)/
cp -av $(CLIENT_SDK_SRC)/* $(CLIENT_SDK_DST)/
build: codegen
cargo build
clean:
cargo clean || echo "Unable to run cargo clean"
distclean: clean
rm -rf $(SERVER_SDK_DST) $(CLIENT_SDK_DST) Cargo.lock
.PHONY: all

View File

@ -0,0 +1,32 @@
# Smithy Rust Server SDK example
This folder contains an example service called Pokémon Service used to showcase
the service framework capabilities and to run benchmarks.
## Build
Since this example requires both the server and client SDK to be code-generated
from their [model](/codegen-server-test/model/pokemon.smithy), a Makefile is
provided to build and run the service. Just run `make` to prepare the first
build.
Once the example has been built successfully the first time, idiomatic `cargo`
can be used directly.
`make dist-clean` can be used for a complete cleanup of all artefacts.
## Run
`cargo run` can be used to start the Pokémon service on
`http://localhost:13734`.
## Test
`cargo test` can be used to spawn the service and run some simple integration
tests against it.
More info can be found in the `tests` folder of `pokemon_service` package.
## Benchmarks
TBD.

View File

@ -0,0 +1,24 @@
[package]
name = "pokemon_service"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
hyper = {version = "0.14", features = ["server"] }
tokio = "1"
tower = "0.4"
tower-http = { version = "0.2", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Local paths
aws-smithy-http-server = { path = "../../" }
pokemon_service_sdk = { path = "../pokemon_service_sdk/" }
[dev-dependencies]
assert_cmd = "2.0"
# Local paths
aws-smithy-client = { path = "../../../aws-smithy-client/", features = ["rustls"] }
pokemon_service_client = { path = "../pokemon_service_client/" }

View File

@ -0,0 +1,212 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/
//! Pokémon Service
//!
//! This crate implements the Pokémon Service.
#![warn(missing_docs, missing_debug_implementations, rust_2018_idioms)]
use std::{
collections::HashMap,
convert::TryInto,
sync::{atomic::AtomicU64, Arc},
};
use aws_smithy_http_server::Extension;
use pokemon_service_sdk::{error, input, model, output};
use tracing_subscriber::{prelude::*, EnvFilter};
const PIKACHU_ENGLISH_FLAVOR_TEXT: &str =
"When several of these Pokémon gather, their electricity could build and cause lightning storms.";
const PIKACHU_SPANISH_FLAVOR_TEXT: &str =
"Cuando varios de estos Pokémon se juntan, su energía puede causar fuertes tormentas.";
const PIKACHU_ITALIAN_FLAVOR_TEXT: &str =
"Quando vari Pokémon di questo tipo si radunano, la loro energia può causare forti tempeste.";
/// Setup `tracing::subscriber` to read the log level from RUST_LOG environment variable.
pub fn setup_tracing() {
let format = tracing_subscriber::fmt::layer()
.with_ansi(true)
.with_line_number(true)
.with_level(true);
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.unwrap();
tracing_subscriber::registry().with(format).with(filter).init();
}
/// Structure holding the translations for a Pokémon description.
#[derive(Debug)]
struct PokemonTranslations {
en: String,
es: String,
it: String,
}
/// PokémonService shared state.
///
/// Some applications may want to manage state between handlers. Imagine having a database connection pool
/// that can be shared between different handlers and operation implementations.
/// State management can be expressed in a struct where the attributes hold the shared entities.
///
/// **NOTE: It is up to the implementation of the state structure to handle concurrency by protecting**
/// **its attributes using synchronization mechanisms.**
///
/// The framework stores the `Arc<T>` inside an [`http::Extensions`] and conveniently passes it to
/// the operation's implementation, making it able to handle operations with two different async signatures:
/// * `FnOnce(InputType) -> Future<OutputType>`
/// * `FnOnce(InputType, Extension<Arc<T>>) -> Future<OutputType>`
///
/// Wrapping the service with a [`tower::Layer`] will allow to have operations' signatures with and without shared state:
///
/// ```compile_fail
/// use std::sync::Arc;
/// use aws_smithy_http_server::{AddExtensionLayer, Extension, Router};
/// use tower::ServiceBuilder;
/// use tokio::sync::RwLock;
///
/// // Shared state,
/// #[derive(Debug, State)]
/// pub struct State {
/// pub count: RwLock<u64>
/// }
///
/// // Operation implementation with shared state.
/// async fn operation_with_state(input: Input, state: Extension<Arc<State>>) -> Output {
/// let mut count = state.0.write().await;
/// *count += 1;
/// Ok(Output::new())
/// }
///
/// // Operation implementation without shared state.
/// async fn operation_without_state(input: Input) -> Output {
/// Ok(Output::new())
/// }
///
/// let app: Router = OperationRegistryBuilder::default()
/// .operation_with_state(operation_with_state)
/// .operation_without_state(operation_without_state)
/// .build()
/// .unwrap()
/// .into();
/// let shared_state = Arc::new(State::default());
/// let app = app.layer(ServiceBuilder::new().layer(AddExtensionLayer::new(shared_state)));
/// let server = hyper::Server::bind(&"0.0.0.0:13734".parse().unwrap()).serve(app.into_make_service());
/// ...
/// ```
///
/// Without the middleware layer, the framework will require operations' signatures without
/// the shared state.
///
/// [`middleware`]: [`aws_smithy_http_server::AddExtensionLayer`]
#[derive(Debug)]
pub struct State {
pokemons_translations: HashMap<String, PokemonTranslations>,
call_count: AtomicU64,
}
impl Default for State {
fn default() -> Self {
let mut pokemons_translations = HashMap::new();
pokemons_translations.insert(
String::from("pikachu"),
PokemonTranslations {
en: String::from(PIKACHU_ENGLISH_FLAVOR_TEXT),
es: String::from(PIKACHU_SPANISH_FLAVOR_TEXT),
it: String::from(PIKACHU_ITALIAN_FLAVOR_TEXT),
},
);
Self {
pokemons_translations,
call_count: Default::default(),
}
}
}
/// Retrieves information about a Pokémon species.
pub async fn get_pokemon_species(
input: input::GetPokemonSpeciesInput,
state: Extension<Arc<State>>,
) -> Result<output::GetPokemonSpeciesOutput, error::GetPokemonSpeciesError> {
state.0.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// We only support retrieving information about Pikachu.
let pokemon = state.0.pokemons_translations.get(&input.name);
match pokemon.as_ref() {
Some(pokemon) => {
tracing::debug!("Requested Pokémon is {}", input.name);
let flavor_text_entries = vec![
model::FlavorText {
flavor_text: pokemon.en.to_owned(),
language: model::Language::English,
},
model::FlavorText {
flavor_text: pokemon.es.to_owned(),
language: model::Language::Spanish,
},
model::FlavorText {
flavor_text: pokemon.it.to_owned(),
language: model::Language::Italian,
},
];
let output = output::GetPokemonSpeciesOutput {
name: String::from("pikachu"),
flavor_text_entries,
};
Ok(output)
}
None => {
tracing::error!("Requested Pokémon {} not available", input.name);
Err(error::GetPokemonSpeciesError::ResourceNotFoundException(
error::ResourceNotFoundException {
message: String::from("Requested Pokémon not available"),
},
))
}
}
}
/// Calculates and reports metrics about this server instance.
pub async fn get_server_statistics(
_input: input::GetServerStatisticsInput,
state: Extension<Arc<State>>,
) -> output::GetServerStatisticsOutput {
// Read the current calls count.
let counter = state.0.call_count.load(std::sync::atomic::Ordering::SeqCst);
let calls_count = counter
.try_into()
.map_err(|e| {
tracing::error!("Unable to convert u64 to i64: {}", e);
})
.unwrap_or(0);
tracing::debug!("This instance served {} requests", counter);
output::GetServerStatisticsOutput { calls_count }
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn get_pokemon_species_pikachu_spanish_flavor_text() {
let input = input::GetPokemonSpeciesInput {
name: String::from("pikachu"),
};
let state = Arc::new(State::default());
let actual_spanish_flavor_text = get_pokemon_species(input, Extension(state.clone()))
.await
.unwrap()
.flavor_text_entries
.into_iter()
.find(|flavor_text| flavor_text.language == model::Language::Spanish)
.unwrap();
assert_eq!(PIKACHU_SPANISH_FLAVOR_TEXT, actual_spanish_flavor_text.flavor_text());
let input = input::GetServerStatisticsInput {};
let stats = get_server_statistics(input, Extension(state.clone())).await;
assert_eq!(1, stats.calls_count);
}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/
// This program is exported as a binary named `pokemon_service`.
use std::sync::Arc;
use aws_smithy_http_server::{AddExtensionLayer, Router};
use pokemon_service::{get_pokemon_species, get_server_statistics, setup_tracing, State};
use pokemon_service_sdk::operation_registry::OperationRegistryBuilder;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
#[tokio::main]
pub async fn main() {
setup_tracing();
let app: Router = OperationRegistryBuilder::default()
// Build a registry containing implementations to all the operations in the service. These
// are async functions or async closures that take as input the operation's input and
// return the operation's output.
.get_pokemon_species(get_pokemon_species)
.get_server_statistics(get_server_statistics)
.build()
.expect("Unable to build operation registry")
// Convert it into a router that will route requests to the matching operation
// implementation.
.into();
// Setup shared state and middlewares.
let shared_state = Arc::new(State::default());
let app = app.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(AddExtensionLayer::new(shared_state)),
);
// Start the [`hyper::Server`].
let server = hyper::Server::bind(&"0.0.0.0:13734".parse().unwrap()).serve(app.into_make_service());
// Run forever-ish...
if let Err(err) = server.await {
eprintln!("server error: {}", err);
}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/
use assert_cmd::prelude::*;
use pokemon_service_client::{Builder, Client, Config};
use std::process::Command;
pub(crate) struct PokemonService {
child_process: std::process::Child,
}
impl PokemonService {
pub(crate) fn run() -> Self {
let process = Command::cargo_bin("pokemon_service").unwrap().spawn().unwrap();
Self { child_process: process }
}
}
impl Drop for PokemonService {
fn drop(&mut self) {
self.child_process
.kill()
.expect("failed to kill Pokémon Service program")
}
}
pub fn client() -> Client<
aws_smithy_client::erase::DynConnector,
aws_smithy_client::erase::DynMiddleware<aws_smithy_client::erase::DynConnector>,
> {
let raw_client = Builder::new()
.rustls()
.middleware_fn(|mut req| {
let http_req = req.http_mut();
let uri = format!("http://localhost:13734{}", http_req.uri().path());
*http_req.uri_mut() = uri.parse().unwrap();
req
})
.build_dyn();
let config = Config::builder().build();
Client::with_config(raw_client, config)
}

View File

@ -0,0 +1,46 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/
// Files here are for running integration tests.
// These tests only have access to your crate's public API.
// See: https://doc.rust-lang.org/book/ch11-03-test-organization.html#integration-tests
use std::time::Duration;
use crate::helpers::{client, PokemonService};
use tokio::time;
#[macro_use]
mod helpers;
#[tokio::test]
async fn simple_integration_test() {
let _program = PokemonService::run();
// Give PokemonSérvice some time to start up.
time::sleep(Duration::from_millis(50)).await;
let service_statistics_out = client().get_server_statistics().send().await.unwrap();
assert_eq!(0, service_statistics_out.calls_count.unwrap());
let pokemon_species_output = client().get_pokemon_species().name("pikachu").send().await.unwrap();
assert_eq!("pikachu", pokemon_species_output.name().unwrap());
let service_statistics_out = client().get_server_statistics().send().await.unwrap();
assert_eq!(1, service_statistics_out.calls_count.unwrap());
let pokemon_species_error = client()
.get_pokemon_species()
.name("some_pokémon")
.send()
.await
.unwrap_err();
assert_eq!(
r#"ResourceNotFoundError [ResourceNotFoundException]: Requested Pokémon not available"#,
pokemon_species_error.to_string()
);
let service_statistics_out = client().get_server_statistics().send().await.unwrap();
assert_eq!(2, service_statistics_out.calls_count.unwrap());
}