DEV Community

Patrick Wendo
Patrick Wendo

Posted on

Using meta-programming in Ruby to build a REST API from a JSON file

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or

send(:get, '/hello', &block)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

JSON response file

HTML file

JSON response

For all 3 defined routes, we manage to get a response. And all with about 29 lines of code.

METAPROGRAMMING

All code is available at my github

Top comments (0)