Nivethan
Posted on November 9, 2020
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 }
}
}
}
}
...
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
...
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)
}
}
...
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
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)
...
! 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
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
)
}
...
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,
)
}
}
...
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))
},
...
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();
...
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
>
The request for when we entered 10 would have looked like:
gemini://gemini.conman.org:1965/hilo/1100?10
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)
...
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!
Posted on November 9, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.