Using meta-programming in Ruby to build a REST API from a JSON file
Patrick Wendo
Posted on October 21, 2024
So I asked ChatGPT, as you do, for stuff I could do with meta-programming in Ruby. If you don't know Ruby has amazing meta-programming support. And meta-programming is basically code that can modify itself while it's running. if you want to get technical with it, meta-programming is a computer programming technique in which computer programs have the ability to treat other programs as their data. Sometimes the program being used as data is the program running. Ruby excels at this.
In this post I would introduce you to AutoAPI. The goal is that you could just write your endpoint specifications in a JSON file and then the program starts a sinatra server that then has all the endpoints. As of now, the program only works for GET endpoints and I will update it as I go. It also returns either JSON or static HTML files. Further updates would be to serve other MIME types. Let's begin.
First of all, I wanted to run this as a shell script (cause I want to practice my scripting) so we start that shebang #!/usr/bin/env ruby
at the top of our file. Because I wanted it to be a shell script that would run from any folder, we have the script looking for a json file called endpoints.json
in the current folder.
# Get the file and parse the entire file into a ruby hash
file = File.read("#{Dir.pwd}/endpoints.json")
endpoints_hash = JSON.parse(file)
We use JSON to parse the file into a ruby Hash. This provides us with quality of life methods that we could use later, should we need to.
Now because we are using Sinatra as our server, we would need a way to dynamically define new endpoints from the file. Sinatra is a DSL for quickly creating web applications in Ruby with minimal effort.
Let's first think about the method send
or public_send
. What these methods do is that they basically perform a method call to a class instance. For instance, if we have a class
class Class
def hello
puts "hello"
end
end
If we did class.send(:hello)
our result would be hello
.
You could also pass parameters to send if the method takes in any arguments.
To create a GET endpoint in Sinatra, we would write
get '/hello' do
'Hello world!'
end
we could dissect this and find that :get
is the method name, '/hello
is the path name and everything in the do block we shall call the do_block
. We could therefore also mentally re-write this as
get('/hello') do
'Hello World'
end
or
send(:get, '/hello', &block)
where &block
is is the do_block
passed to the send method.
All this to say, we need to define a method that will read the endpoint names and their associated methods to define new routes. It looks like this
def create_endpoint(method, name, &block)
Sinatra::Application.instance_eval do
name = "/#{name}" if not name.start_with?(/\//)
send(method, name, &block)
end
end
It takes in the REST method(GET, POST, PUT, DELETE), endpoint name and a code block. It then uses instance_eval to create a new route on the running instance of Sinatra. I added a check to ensure that the the endpoint name is preceded with a forward-slash because I ran into this random bug where the route would seem to be defined, but not accessible because it does not start with a forward-slash. finally, we just send the method as shown above. Simple, right?? Honestly, it's that simple.
Now we should specify that the endpoints.json
file has a specific structure.
{
"GET": {
"json_response": {
"header": {"Content-Type": "application/json"},
"response": {
"content": { "message": "Hello AutoAPI"},
"file": false
}
},
"json_response_file": {
"header": {"Content-Type": "application/json"},
"response": {
"file": true,
"content": "endpoints.json"
}
},
"html_file": {
"header": {"Content-Type": "text/html"},
"response": {
"file": true,
"content": "test.html"
}
}
}
}
This is a sample of the endpoints file. We shall go through each endpoint one at a time. So all the REST verbs act as keys, that way all the GET routes are in the GET values etc etc. Our first endpoint is the json_response
. This one was supposed to test if I could get a hard coded response. The header is a nested object containing what you would expect in a typical HTTP GET request. Here we are passing the Content-Type only. For the response, we have a file key that specifies if the endpoint should send a file, or if it should send the value of content as JSON. This file should also be in the same folder as the script when running it. In this example, the second endpoint actually just returns the endpoints.json
file. The third endpoint has a different content type and returns a HTML file.
To process these endpoints, this is the code I wrote
endpoints_hash.each do |method, paths|
paths.each do |path, params|
create_endpoint(method.downcase.to_s, "#{path.to_s}") do
content_type :json if params["Content-Type"] == "application/json"
content_type :html if params["Content-type"] == "text/html"
if params["response"]["file"]
send_file "#{Dir.pwd}/#{params["response"]["content"]}"
else
params["response"]["content"].to_json
end
end
end
end
All it does is iterate through all the endpoints and paths and defines routes for them. Note that I have to specify the content_type and I just use the passed headers to figure that out. The code might be a bit breakable and is a work in progress, but thus far, it works as seen below
For all 3 defined routes, we manage to get a response. And all with about 29 lines of code.
All code is available at my github
Posted on October 21, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 21, 2024