DEV Community

loading...

A Gemini Client in Rust - 06 Gemini Statuses

Nivethan
Originally published at nivethan.dev Updated on ・7 min read

Hello! We currently have our Gemini client working not badly. We have a visit function that can connect to Gemini pages over TLS and we have a URL object that can be used to navigate to pages. The next thing we need to do is start handling the various statuses that Gemini can throw!

There are 6 statuses for a full Gemini client but for basic clients we only need to implement 4.

So far, we haven't done anything to differentiate the different statuses. We simply check our TLS session for data and print it to the screen. We assume our request will succeed, so we print out the status and we immediately poll our session again to get the response body.

Now we will close our session if the request failed, follow redirect statuses to their new URLs and handle successful requests properly!

Let's get started!

Gemini Responses

Before we start dealing with statuses, we need to first deal with responses. This means that we should construct a response object out of what the Gemini servers send back.

...
#[derive(Debug)]
struct Status {
    code: String,
    meta: String,
}

impl Status {
    fn new(status: String) -> Self {
        let tokens: Vec<&str> = status.splitn(2, " ").collect();
        Status {
            code: tokens[0].to_string(), 
            meta: tokens[1].to_string()
        }

    }
}

#[derive(Debug)]
struct Response {
    status: Status,
    mime_type: Option<String>,
    charset: Option<String>,
    lang: Option<String>,
    body: Option<String>,
}

impl Response {
    fn new(data: String) -> Self {
        let tokens: Vec<&str> = data.splitn(2, "\r\n").collect();
        let status = Status::new(tokens[0].to_string());

        match status.code.chars().next().unwrap() {
            '2' => {
                let mime_type = Some("text/gemini".to_string());
                let charset = Some("utf-8".to_string());
                let lang = Some("en".to_string());

                let body;
                if tokens[1] != "" {
                    body = Some(tokens[1].to_string());
                } else {
                    body = None;
                }

                Response { status, mime_type, charset, lang, body }
            },
            _ => {
                Response { status, mime_type: None, charset: None, lang: None, body: None }
            }
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

This may look like a lot but it really is straightforward. The first thing we need to look at is our Response struct. Here you can see the key pieces of information we need. We have a status object, a mime type, charset, language and finally the body.

Our status object in turn is really just the raw data we got from the Gemini server.

When we construct a Response, we take in a String.

Now the next line is key! Due to the way rustls is interacting with the Gemini server, we aren't always getting one just the status line, or the status line and body.

Depending on something I don't know, the server sometimes sense the status line and response body in one group and other times it sends the status line and the response body comes moments after.

This is why the first thing we do is split the String on carriage return line feed and we split it so we only get 2 attributes.

We make a Status object using the first part of the token. Had we not received a response body the second part of the tokens would be a empty string, "".

Now the next step is to process the status code.

For now we are going to only handle the success code starting with 2. We match on 2 and we will then set the various parts of the response. For now we will hard code in the values, and later on we will actually parse the status line.

Gemini status codes are 2 digits but because we are working on a basic client, we'll focus on just the first digit of statuses. This is enough information to get going.

The next step is figure out if we received the body or not. If the second element in the tokens array is blank, then we didn't receive a body. If it isn't blank then we did receive a body.

We then return the Response back.

If the status was anything other than 20, we set the response variables to None and only fill in the status.

Now let's look at how we use the Response in our visit function.

Status of 2x - Success

Let's first implement the handing of a status starting with 2. This means that we have a successful response and we should be getting a response body. Based on the mime type we may do different things but for now we will just print out what we get to the screen.

The test case I used for this was the Solderpunk's Gemini page but feel free to use any Gemini page to play with!

> visit gemini.circumlunar.space
Enter fullscreen mode Exit fullscreen mode
...
fn visit(url: Url) {
    ...
    while client.wants_read() {
        client.read_tls(&mut socket).unwrap();
        client.process_new_packets().unwrap();
    }
    let mut data = Vec::new();
    let _ = client.read_to_end(&mut data);

    let mut response =  Response::new(String::from_utf8_lossy(&data).to_string());

    match response.status.code.chars().next().unwrap() {
        "2" => {
            if response.body == None {
                client.read_tls(&mut socket).unwrap();
                client.process_new_packets().unwrap();
                let mut data = Vec::new();
                let _ = client.read_to_end(&mut data);

                response.body =  Some(String::from_utf8_lossy(&data).to_string());
            }

            println!("{}", response.body.unwrap_or("".to_string()))
        },
        _ => println!("Error - {} - {}", response.status.code, response.status.meta)
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Our updated visit function will now process the first response it gets from the server and will make it into a Response object. Next we match against it to decide what we want to do. The first case is the easiest, if we get a code starting with 2 we have a successful status.

If the request was successful, we need to check the body. We may have received the body with the status or we may need to check our TLS session for more data.

If the response.body is None, this means we need to check our session and once we have the data we update the Response object with that information.

If there is something in the response.body then we can simply print the page.

The catch all in our match statement is for our errors, feel free to try requesting a page that doesn't exist and the Gemini server should respond with a not found error message.

Status of 3x - Redirect

Now that we have our 2s working, lets add our 3s. Statuses starting with a 3 mean that the page has moved and that this is a redirect. The meta field in the status is the new url we need to request.

> visit zaibatsu.circumlunar.space/spec-spec.txt
Error - 31 - gemini://gemini.circumlunar.space/docs/spec-spec.txt
Enter fullscreen mode Exit fullscreen mode

Currently this is what happens when we request a page that has moved, we see the error type starts with a 3 and the meta field is the URL we should request.

Let's handle the redirects!

...
 '2' => { ... },
 '3' => visit(Url::new(&response.status.meta)),
  _ => println!("Error - {} - {}", response.status.code, response.status.meta)
...
Enter fullscreen mode Exit fullscreen mode

! In this case handling redirects is very simple, if we get a status starting with 3, we can simply call the visit function again, passing in the meta field of the status.

Now we should be able to try the visit command again and this time our Gemini client will follow the redirect!

Status of 1x - Input

Now that we see how redirects work, we can now look at the final status we need to worry about. The statuses starting with 1 mean that the Gemini server is expecting input. The meta field is the prompt and once the user answers we then add it the URL we are currently on as a query parameter.

The generic way of adding parameters is to append the value with a question mark.

> visit gemini.conman.org/hilo/1078?50
Enter fullscreen mode Exit fullscreen mode

Here we are submitting 50 to the Gemini page located at hilo/1078.

There is a guessing game at gemini.conman.org/hilo/ that is very helpful to test our Input status type.

Let's get started!

...
    fn for_dns(&self) -> String {
        format!("{address}", address=self.address)
    }
    fn input_request(&self, input: String) -> String {
        format!("{scheme}://{address}:{port}/{path}?{input}\r\n",
            scheme = self.scheme,
            address = self.address,
            port = self.port,
            path = self.path,
            input = input
        )
    }
...
Enter fullscreen mode Exit fullscreen mode

Inside our Url object methods, we're going to add a new formatter. We are going to add a function that can append arguments to our url this way we can generate urls where we are passing back input.

The next step is to update our request function in our url object.

...
    fn request(&self) -> String {
        if self.query == "" {
            format!("{scheme}://{address}:{port}/{path}\r\n",
                scheme = self.scheme,
                address = self.address,
                port = self.port,
                path = self.path
            )
        } else {
            format!("{scheme}://{address}:{port}/{path}?{query}\r\n",
                scheme = self.scheme,
                address = self.address,
                port = self.port,
                path = self.path,
                query = self.query,
            )

        }
    }
...
Enter fullscreen mode Exit fullscreen mode

Now we check to see if we have a query, it is probably better to make these attributes of the URL into Options so that we can check against None instead of blank but for now this is fine. If we don't have a query then we generate a url as usual. If we do have a query however, then we append a ? and our query variable.

Now let's look at how we use these 2 functions.

...
    match response.status.code.chars().next().unwrap() {
        '1' => {
            print!("{prompt} ", prompt=response.status.meta);
            io::stdout().flush().unwrap();

            let mut answer = String::new();
            io::stdin().read_line(&mut answer).unwrap();

            let dest = url.input_request(answer.trim().to_string());
            visit(Url::new(&dest))
        },
...
Enter fullscreen mode Exit fullscreen mode

Inside our visit function we now have added logic to handle the Gemini responses with a status starting with 1. In this case we first print out the meta field as that is the prompt the user should see.

We then wait for input from the user. We then take this input and create a request that will add our the input as the query parameter.

Finally we call our visit function again.

The next time our visit function runs, when we get to the following line:

...
stream.write(url.request().as_bytes()).unwrap();
...
Enter fullscreen mode Exit fullscreen mode

The request() function will see that we have a query available so it will create a request with the query parameter incorporated in it.

Voila! We have inputs working in Gemini! At this point we should be able to play the guessing game at gemini.conman.org/hilo/.

> visit gemini.conman.org/hilo/1100
Guess a number 10
Higher 50
Lower 25
Higher 40
Lower 35
Lower 32
Higher 33

Congratulations!  You guessed the number!

=> /hilo/1093 Try again?
=> / Nah, take me back home

>
Enter fullscreen mode Exit fullscreen mode

The request for when we entered 10 would have looked like:

gemini://gemini.conman.org:1965/hilo/1100?10
Enter fullscreen mode Exit fullscreen mode

Statuses of 4x, 5x, and 6x - General Errors.

Currently the remaining statuses fall into our catch alls in our match statement inside our visit function.

...
        _ => println!("Error - {} - {}", response.status.code, response.status.meta)
...
Enter fullscreen mode Exit fullscreen mode

We're going to leave this as is for as the Gemini specification allows this and we are building a basic client.

! We are done! We have inputs, redirects, success and errors being handled now. We do have a hacky bit still left, in the creation of our Response object we hard coded the mime type, charset and lang values, we're going to leave this as is for now as, once we finish up the processing of a gemini page we can then circle back to fixing that up!

In the next chapter we're going to add a few more commands to our client.

See you soon!

Discussion (0)