Talks

Generating a custom SDK for your web service or Rails API

RubyKaigi 2024

00:00:11.200 Hello, my name is Matt Muller, and I am a Software Development Engineer at AWS. This presentation is about generating a custom Ruby SDK for your web service or Rails API using Smithy, a modeling language developed by AWS. Here is the agenda for today's talk: I will briefly talk about who I am, present an example use case and service for a generated Ruby SDK, provide an overview of Smithy itself, and explain how it can be used to model and generate a Ruby SDK. Next, we will do a deep dive together into the SDK usage for both the public and private components. Finally, I will conclude the talk with some final thoughts.
00:00:28.560 As I mentioned before, my name is Matt, and I am a software engineer at AWS, working on the AWS SDK for Ruby team. If you've ever opened up an issue in the AWS SDK Ruby repository on GitHub, you've probably interacted with me. I advocate for Ruby in our company. This is my first time professionally presenting, and it is also my first time in Japan, so please go easy on me if I make a mistake. Without further ado, let's get right into it.
00:01:16.880 Here is an example use case for why you might want to use Smithy and Ruby. Let’s say you and your team own a Rails API that is deployed somewhere. You and your customers may try to call the API in several ways, such as using curl or even writing your own connection code. However, this is not a great solution since they are too low level or expensive to maintain. I think we can do better. In this situation, you may want a full library to interact with your API, written in Ruby, of course. You want something that is extensible, easy to use, code-generated, and robust. We'll explore how we can achieve this with Smithy.
00:02:14.400 For this example, we're going to create a fake high score service following the command line guide in the Rails documentation. First, we need a sample Rails API. If you haven't used Rails before, we can use the new command with the --api option along with the name of our API. This will create a bare-bones service for handling requests and responses using JSON by default. Next, we will generate the high score model using a scaffold. Here, I will specify a game field as a string and a score field as an integer. This creates a route, controller, model, and everything you need for a high score entry. We will then migrate the database with the db:migrate command. This essentially adds the columns to the database for our new model, which is all standard for Rails applications.
00:03:06.080 Finally, we run the server with 'rails s', which starts the service on the localhost endpoint. Great! Now we can verify that our API is responsive. Here we can use curl against the endpoint route. We can see that we get back an empty JSON array because we have no high scores registered. Fantastic! Everything works. But now let's focus on the customer who wants something easier to use, written in Ruby of course. But first, what is Smithy? Smithy is a language for defining services in SDKs. Smithy models are defined using shapes. Shapes are named data definitions that describe the structure of an API. For this example, a Weather Service shape is defined, which has a city resource shape. The city resource is a way of describing operations on the city shape, such as getCity and listCities. There is also an additional operation called getCurrentTime.
00:04:05.760 There are many benefits to using Smithy. It is protocol agnostic and works in any programming language. You can use it to describe services running in any environment, and it works with any transport format such as JSON, XML, or protobuf. Smithy models are designed to evolve and can be extended through traits. Its meta model allows you to avoid breaking changes for your customers. Smithy also supports API governance, customizable API standards, and validation of your API definitions. Finally, Smithy is resource-based; models are defined by resources and operations, which gives you rich information about your models.
00:05:04.400 Now, let's install the Smithy CLI. You can install it in various ways, such as Homebrew on Mac or from source builds. You can visit Smithy's documentation for more details. Now let's define the high score service as a Smithy model. We first create a Smithy file in a model directory that defines the version and our namespace, which I will call 'my app'. From there, we can declare some imports for the service. We're going to use the Rails JSON protocol, a protocol that interfaces nicely with Rails applications. The protocol is just a representation of the data over the wire, such as JSON or XML. You can implement your own protocol if you wish.
00:05:41.960 Most importantly, we define our service, the high score service, with a high score resource. Then, we annotate the service with the Rails JSON protocol to specify that the service implements this protocol. The high score resource essentially defines our CRUD actions. We define create, read, update, delete, and list operations for the resource, and the identifier will be the default ID that Rails uses for its models. Let’s focus on the create high score operation as an example. The create high score should be a POST request against the high scores URI, which has both an input and output shape as well as a list of potential errors that might be raised. We can map our input to the same as our high score parameters in our Rails controller.
00:06:54.920 Our output will be a serialized version of the high score model as JSON. If saving fails, our model will render errors with an unprocessable entity error, which we define as a possible error in the model. Here are the input and output shapes: we specify that the input must require a high score member that maps to our high score parameters. Our output shape includes our high score attributes as a JSON payload. The high score parameters are from our input; we take the game and score fields to create the high score with our controller permitting these two values under a high score hash. The high score attributes are derived from our output; it's simply a representation of our high score model.
00:07:45.360 We have a mapping of our game as a string and our score as an integer, and we also include the created and updated timestamps. Now it's time to build our Ruby SDK using Smithy. First, we need to define the Smithy build JSON file. In this file, we declare a dependency on the Smithy Ruby Rails codegen package, which facilitates the generation. Next, we declare the model folder containing our Smithy files as the source. We then set up a projection that will use the high score service as a generator shape finally. We configure the projection with a Ruby codegen plugin that defines the service, the module name we want, and other gem specification values, such as the version and the gem name.
00:08:33.920 Now we have our model and the Smithy build file, and it's time to generate our SDK. We simply execute 'smithy build', and we see in our output that the model was valid and it built successfully. The output of that generation shows that we have a gem along with a gemspec. Everything here is fairly standard. We have a lib folder for our code and a sig folder for RBS. When we change directories to the high score service output and start up IRB, we can load lib and require the high score service. This allows us to demonstrate usage by creating a client object.
00:09:29.760 I will go more into detail about these components, but it works similarly to the AWS SDK for Ruby products. Our client is configured with our Rails endpoint. We can call the list high scores method, and similar to our curl output, we see that we get back an empty high scores array. Sweet! Next, let’s create a high score. We can call our create high score method and pass it a high score hash. For example, we have a game of Frogger and a score of over 9,000. We can see that the output includes our data object with the new high score, which was created with an ID of one, along with the game and score we defined, as well as the created and updated timestamps.
00:10:33.500 There's also a location header that was parsed. Now, if we list our high scores again, we can see that our high score is present. Cool! Next, let’s try to delete our high score, just in case it was a mistake. We will call delete high score with our ID of one and we can see that we get back a successful response with no error and an empty output. If we try to delete this high score again, we will get back a not found error that indicates that the high score was not found.
00:11:24.640 So, how does it work? The SDK operates on a middleware system similar to Rack. We have an input object that passes through middleware with each component having a specific duty and delegating to the next one. We can see that our input flows through some initialization steps: validation, request building, retry handling, and finally, it's sent to the service. The service response is then parsed, and if it’s a retryable error, it may be retried before flowing out as output. Why go through all this? There are many benefits to a Smithy Ruby SDK, which I will demonstrate. The SDK is easy to use and boasts rich features. It is highly extensible at runtime, is code-generated to ensure stability every time you make changes to your model, and allows you to implement your own protocol and integrations at build time.
00:12:29.240 Let’s dive into the public features of a Smithy Ruby SDK. The most important piece is the client, which is your API entry point. It accepts configuration values such as the endpoint. Other public features include allowing you to configure options and components for your SDK. Config is passed into your client, and you can even override your client configuration per operation call. In the example we just saw, the endpoint gets passed as config to our client and is used as intended. We can also see that if we try to call list high scores with a different endpoint option, our client config is superseded with the operation config. If this alternate endpoint doesn't exist, we get back a networking error.
00:13:31.920 Next, let’s discuss types. Types are very simple; they are essentially data containers for the input and output shapes. You can think of them as glorified structs. In our previous example, when we created a high score, we got back a high score output type that contained a nested type for high score attributes. This type includes members for ID, game, score, etc. The SDK also supports logging using a standard Ruby logger, which is helpful for request information, debugging SDK internals, or enabling wire logging. In this example, we configure our client with a logger to standard out and set the log level to info. When we call list high scores, we can see some info related to logs with parameters and the structure that was parsed. On the other hand, debug level logs are more comprehensive.
00:14:40.000 The SDK supports native errors and error handling. These errors are standard Ruby exceptions, and they follow a hierarchy which we will see shortly. Errors can be modeled or unmodeled and are handled the same way. In this example, we deleted our high score, but then we attempted to delete it again. When that second deletion failed, we received our not found error. This error was not modeled; it is just a generic API client error with the code 'not found.' We could have instead modeled this error and attached it to the operation, which would return a not found error class. This is useful if you want to add documentation, retry the error, or have additional data attached to the error.
00:15:37.680 To demonstrate modeled errors, let's look at the unprocessable entity error case. We can add a validation to our Rails model that checks the length of a game name to be at least two characters long. Similarly, we can add a validation to our Smithy model when we describe the service. In this case, we will call create high score and wrap it in a begin-rescue block. We can use a game called 'X' which will fail validation. We expect to see some API error and print out the class and data. As expected, the error we see is an unprocessable entity error. In the data of this error, we find that the validation performed by Rails—requiring the game name to be too short—is raised, demanding a minimum length of two characters.
00:16:35.320 This illustrates what modeled errors look like in the service. For every protocol, there is an error code method responsible for mapping the HTTP response to the corresponding error class name. There is a generic API error from which all errors inherit. This includes generic client, server, and redirect errors for HTTP 3, 4, and 5xx status codes. Furthermore, we have generated model errors; in this case, we only have the unprocessable entity error. The SDK also supports custom HTTP clients of your choosing, as long as they conform to the required interface. We include a default client that uses net/http; here we initialize this default HTTP client with a read timeout of one second and enable debug output to inspect the wire logs.
00:17:31.600 We can also create and use a custom HTTP client, given that it defines a transmit method with request and response parameters. This ability might be useful if you want control over sockets or utilize alternative implementations such as curb, HTTParty, or Faraday. We initialize our API client using this HTTP client, and after calling list high scores, we see that the debug output captures the expected actions, confirming that our net/http client was utilized correctly. The SDK supports retrying requests and various retry strategies. It retries transient network errors as well as errors that you model as retryable. You can also integrate a custom retry strategy that will specify which errors to retry and the backoff strategy to use.
00:18:54.420 For this example, let's make the unprocessable entity error retriable. In practice, this might not make much sense because validations will always happen. However, we want to demonstrate that a modeled error could be retried. We can observe that our generated error indeed has a retryable method that returns true, allowing for integration into retry logic that recognizes it as retryable. For this reason, we set up our client, enabling wire logging to track the retry count. When we call create high score with an invalid game, we check our debug logs to confirm the process. Here, we can see that the operation finished with two retries, totaling three requests.
00:20:00.320 The SDK has two default retry strategies: standard and adaptive. Both feature some backoff algorithms and retry logic using retry tokens. These retry strategies are important for minimizing service outages; we want to control the frequency of retries rather than attempt them all immediately. You may also create your own retry strategy provided it adheres to the required methods. Let’s now talk about a testing feature called stubbing, where we can mock responses for testing in RSpec and Minitest without making network requests. In this example, we create a client with stub responses set to true.
00:21:18.720 When stub responses is activated, we indicate that we want to stub list high scores with some mocked data that includes a fake game and a negative score. We subsequently define the next stub as a network error. We can see that the first time we call list high scores, the fake game is returned in the list, while the second time yields a networking error. In your application, it's crucial to handle expected and unexpected responses from the service. The SDK also supports hooks known as interceptors, which are a feature that lets you integrate into the SDK's life cycle.
00:22:13.960 Interceptors allow you to modify the input and output requests and response objects during any stage of execution. Here, we define an interceptor called Network Interceptor that includes two methods: read before transmit and read after transmit. This interceptor tracks how long the network call takes by capturing the time before transmitting the request and after, then calculating the difference. The interceptor also prints the request and response headers. We register this interceptor in our interceptor list and initialize the client with it. After we invoke list high scores, we can see that the request and response headers are printed as expected, along with the total duration of the request.
00:23:27.440 Here are the available hooks for interceptors: you can intercept operations before and after execution, during serialization, signing, transmitting, deserialization, and throughout any portion of the retry loop. The SDK also supports plugins, which are distributable blocks of code that modify and wrap configuration values. You can use them to bundle features for your customers, enabling them to enhance their usage experience. In this example, we will encapsulate our Network Interceptor in a plugin by adding the interceptor to the existing interceptor configuration. This allows customers to install and use this plugin if they want to utilize network intercepting functionality.
00:24:29.320 We then register the plugin in our plugin list and initialize the client with the plugin list before calling list high scores. We can see that our hybrid plugin executes the interceptor as expected. The SDK also supports waiters; waiters block code execution until a specified condition is satisfied. They work by continuously polling the service for this condition. AWS leverages waiters for various processes, such as having S3 wait for a bucket to be created before inserting objects into it. For this example, we define a waitable trait in Smithy, which is attached to get high score, wherein we specify a single waiter called high score exists.
00:25:23.840 This waiter utilizes acceptors that assess whether to continue waiting. If the high score is absent, we resume waiting; if it is found, we return success and halt. To use this feature, we establish our client and might consider some asynchronous operation that creates a high score while returning the intended ID. We then create a waiter with a maximum wait time of ten seconds, waiting for ID 1 to be created, allowing up to ten seconds. In this case, if our high score service is created within the limit, it returns true. However, if we wait for ID 2 to be created and the operation is slow or fails, we see a maximum wait time exceeded error.
00:26:51.840 The SDK supports pagination, where the paginator automatically manages the pagination of your service calls using continuation tokens. It will return enumerators for both pages and items of your API. In this example, we define the paginated trait on list high scores, specifying the input and output tokens to use for pagination, as well as the page size. Optionally, we can define what items we want to model. In the input, we add nextToken as a string and maxResults as an integer as query parameters. In the output, we include nextToken to define its usage.
00:27:48.160 To utilize pagination, we create our client again, and then we establish our paginator class, passing it to the client. The paginator pages method provides an enumerator; the first item represents our initial page, which contains a portion of high scores up to max results. We can also call the items method, resulting in another enumerator. The first item represents the high scores modeled without any page or metadata from the response. The SDK supports additional features like modeled authentication, allowing you to authenticate your service API calls. You can use Smithy to define authentication mechanisms such as API key, HTTP basic, digest, or even SIGv4. Rails provides tools for most of these out of the box.
00:28:38.880 To add these to your API, you can also create your own authentication scheme if you need dynamic endpoint resolution based on input at runtime or using complex modeled rules. For instance, you might want to redirect to different endpoints depending on some input, facilitating client-side load balancing or A/B testing. In this case, custom DNS resolution using Ruby's resolve method or your own DNS resolver connection pooling can be employed, reusing them in your custom HTTP client when necessary. Ultimately, the SDK embodies the spirit of innovation in building the next great SDK. Now we will quickly do a deep dive into the internals for our curiosity and to better understand how the SDK operates behind the scenes.
00:30:12.200 These interfaces are crucial to know for debugging or especially if you are going to be vending this SDK to your customers. The builder is responsible for constructing transport requests according to your protocol rules. For example, we see how the builder for create high score constructs a POST request, which includes a JSON body with the high score information. The high score parameters are structured and nested appropriately into the body.
00:30:54.680 Parsers are the inverse of builders; they parse an HTTP response in accordance with the protocol rules into a type that eventually gets returned to the user. They also parse header information like the location, ensuring that the input is validated against the expected type. In this process, we ensure that high score parameters are presented correctly: game as a string and score as an integer, returning helpful errors when the conditions are unfulfilled. Params modules merely facilitate converting a simple Ruby hash as input into a structured input type. This guarantees consistency in how input and output are handled across all interceptors, avoiding discrepancies like working with different formats—such as a hash for input and a struct for output.
00:32:53.000 Lastly, the entire framework of the SDK is driven by middleware, reminiscent of the Rack pattern. We create a middleware stack, systematically adding each middleware in a prescribed order. For instance, we initialize our request, validation, input construction, authentication calculation, and endpoint determination, implementing retries, signing requests, and registering parsing middleware all before sending the request out. To conclude, what do you think about Smithy Ruby? It is currently in an experimental phase, and we would greatly appreciate any feedback. It is entirely open-source, along with Smithy itself. This system will become the backbone of a future version of the AWS SDK for Ruby.
00:33:44.960 Thank you for listening! I am genuinely excited to have this opportunity to speak at this conference and share my work with you all. You can find me on GitHub, but I do not have any other social media. For those interested, the Smithy Ruby repository link is on the left. You can also join in this demo by visiting the demo repo under my GitHub, which has everything you need to get started with the high score service.
00:34:53.520 Thank you again.