commit - /dev/null
commit + 1d126b158f872e4cad8db4a9bd5ac8a9d9cb6355
blob - /dev/null
blob + 619eb117f132cfa1718799bbee7f2413bc095dc9 (mode 644)
--- /dev/null
+++ .gitignore
+.cpcache
+.nrepl-port
+target
+
blob - /dev/null
blob + 3267c8bbfaa4c9343ea1a2383dfd3b8c3bbc776b (mode 644)
--- /dev/null
+++ README.md
+# Gemini library for clojure
+
+`gemini.core` is a clojure library to make Gemini requests.
+
+
+## Usage
+
+```clojure
+user=> (require '[gemini.core :as gemini])
+```
+
+#### fetch
+
+`fetch` makes a Gemini request and returns a map with `:request`,
+`:meta`, `:code` and `:body` as keys, or `:error` if an error occur.
+
+The request needs to be closed afterwards using `close`, or calling
+the `.close` method on the `:request` object.
+
+```clojure
+user=> (gemini/fetch "gemini://gemini.circumlunar.space/")
+{:request
+ #object[com.omarpolo.gemini.Request 0x3b270767 "com.omarpolo.gemini.Request@3b270767"],
+ :meta "gemini://gemini.circumlunar.space/",
+ :code 31,
+ :body
+ #object[java.io.BufferedReader 0x49358b66 "java.io.BufferedReader@49358b66"]}
+```
+
+#### body-as-string!
+
+Read all the response into a string and returns it. It also closes
+the request automatically.
+
+```clojure
+user=> (-> (gemini/fetch "gemini://gemini.circumlunar.space/")
+ gemini/body-as-string!)
+"# Project Gemini\n\n## Overview\n\nGemini is a new internet protocol which..."
+```
+
+#### close
+
+Closes a request.
+
+#### with-request
+
+Like `with-open`, but specifically for the requests:
+
+```clojure
+user=> (with-request [req (fetch "gemini://gemini.circumlunar.space/")]
+ ,,,)
+```
blob - /dev/null
blob + 0fa37ac8be5938dd0fbe741f493f3776fbb3e8a8 (mode 644)
--- /dev/null
+++ build.clj
+(ns build
+ (:require [clojure.tools.build.api :as b]))
+
+(def lib 'com.omarpolo/gemini)
+(def version (format "0.1.0"))
+(def class-dir "target/classes")
+(def basis (b/create-basis {:project "deps.edn"}))
+(def jar-file (format "target/%s-%s.jar" (name lib) version))
+
+(defn clean [_]
+ (b/delete {:path "target"}))
+
+(defn compile [_]
+ (b/javac {:src-dirs ["src"]
+ :class-dir class-dir
+ :basis basis
+ :javac-opts ["-source" "11" "-target" "11"]}))
+
+(defn jar [_]
+ (compile nil)
+ (let [repo "github.com/omar-polo/gemini.git"]
+ (b/write-pom {:class-dir class-dir
+ :lib lib
+ :version version
+ :basis basis
+ :src-dirs ["src"]
+ :scm {:connection (str "scm:git:git://" repo)
+ :developerConnection (str "scm:git:ssh://git@" repo)
+ :tag version
+ :url (str "https://" repo)}}))
+ (b/copy-dir {:src-dirs ["src"]
+ :target-dir class-dir})
+ (b/jar {:class-dir class-dir
+ :jar-file jar-file}))
blob - /dev/null
blob + cd7784ac5aa074018fba1b8f2d8b0715713e41c0 (mode 644)
--- /dev/null
+++ deps.edn
+{:paths ["src" "target/classes"]
+
+ :aliases
+ {:build {:deps {io.github.clojure/tools.build {:tag "v0.6.2"
+ :sha "7ef409b370312c8819c587aa648177331602fa44"}}
+ :ns-default build}}}
blob - /dev/null
blob + e06d244a4b22ece7e1cd9cbb01acc35872a7716f (mode 644)
--- /dev/null
+++ src/com/omarpolo/gemini/Request.java
+package com.omarpolo.gemini;
+
+import javax.net.ssl.*;
+import java.io.*;
+import java.net.*;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.NoSuchElementException;
+import java.util.Scanner;
+
+public class Request implements AutoCloseable {
+
+ private final BufferedReader in;
+ private final PrintWriter out;
+ private final SSLSocket sock;
+
+ private final int code;
+ private final String meta;
+
+ public static class DummyManager extends X509ExtendedTrustManager {
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return null;
+ }
+ }
+
+ public static class MalformedResponse extends Exception {}
+
+ public Request(String uri) throws IOException, MalformedResponse, URISyntaxException {
+ this(new URI(uri));
+ }
+
+ public Request(URL url) throws IOException, MalformedResponse {
+ this(url.getHost(), url.getPort(), url.toString());
+ }
+
+ public Request(URI uri) throws IOException, MalformedResponse {
+ this(uri.getHost(), uri.getPort(), uri.toString());
+ }
+
+ public Request(String host, int port, URL req) throws IOException, MalformedResponse {
+ this(host, port, req.toString());
+ }
+
+ public Request(String host, int port, URI req) throws IOException, MalformedResponse {
+ this(host, port, req.toString());
+ }
+
+ public Request(String host, int port, String req) throws IOException, MalformedResponse {
+ if (port == -1) {
+ port = 1965;
+ }
+
+ sock = connect(host, port);
+
+ var outStream = sock.getOutputStream();
+ out = new PrintWriter(
+ new BufferedWriter(new OutputStreamWriter(outStream)));
+
+ out.print(req);
+ out.print("\r\n");
+ out.flush();
+
+ var inStream = sock.getInputStream();
+ in = new BufferedReader(new InputStreamReader(inStream));
+
+ var reply = in.readLine();
+
+ if (reply.length() > 1027) {
+ throw new MalformedResponse();
+ }
+
+ var s = new Scanner(new StringReader(reply));
+ try {
+ code = s.nextInt();
+ s.skip(" ");
+ meta = s.nextLine();
+ } catch (NoSuchElementException e) {
+ throw new MalformedResponse();
+ }
+ }
+
+ public SSLSocket connect(String host, int port) throws IOException {
+ try {
+ var params = new SSLParameters();
+ params.setServerNames(Collections.singletonList(new SNIHostName(host)));
+
+ var ctx = SSLContext.getInstance("TLS");
+ ctx.init(null, new DummyManager[]{new DummyManager()}, new SecureRandom());
+ var factory = (SSLSocketFactory) ctx.getSocketFactory();
+
+ var socket = (SSLSocket) factory.createSocket(host, port);
+ socket.setSSLParameters(params);
+ socket.startHandshake();
+ return socket;
+ } catch (NoSuchAlgorithmException | KeyManagementException e) {
+ throw new RuntimeException("Unexpected failure", e);
+ }
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getMeta() {
+ return meta;
+ }
+
+ public BufferedReader body() {
+ return in;
+ }
+
+ public void close() throws IOException {
+ in.close();
+ out.close();
+ sock.close();
+ }
+}
blob - /dev/null
blob + 665dc45d1769bd4eca626027f74307b72cc68486 (mode 644)
--- /dev/null
+++ src/gemini/core.clj
+(ns gemini.core
+ (:require
+ [clojure.java.io :as io])
+ (:import (com.omarpolo.gemini Request)))
+
+(defmacro ^:private request->map [& args]
+ `(try
+ (let [req# (Request. ~@args)]
+ {:request req#
+ :code (.getCode req#)
+ :meta (.getMeta req#)
+ :body (.body req#)})
+ (catch Throwable e#
+ {:error e#})))
+
+(defn fetch
+ "Make a gemini request. `uri` may be a URI, URL or string, and
+ represent the request to perform. `host` and `port` are extracted
+ from the given `uri` in not given, and port defaults to 1965. The
+ returned request needs to be closed when done, or use the
+ `with-request` macro."
+ ([uri]
+ (request->map uri))
+ ([host uri]
+ (fetch host 1965 uri))
+ ([host port uri]
+ (request->map host port uri)))
+
+(defn body-as-string! [{r :request}]
+ "Read all the response into a strings and returns it. The request
+ will be closed."
+ (let [sw (java.io.StringWriter.)]
+ (with-open [r r]
+ (io/copy (.body r) sw)
+ (.toString sw))))
+
+(defn close [{r :request}]
+ (.close r))
+
+(defmacro with-request [[var req] & body]
+ "Make a request, eval `body` when it succeed and automatically close
+ the request, or throw an exception if the request fails."
+ `(let [,var ,req]
+ (when-let [e# (:error ,var)]
+ (throw e#))
+ (with-open [req# (:request ,var)]
+ ~@body)))
+
+(comment
+ (with-request [req (fetch ,,,)]
+ ,,,)
+ )
+
+
+;; helpers
+
+(def code-description
+ "Human description for every response code."
+ {10 "input"
+ 11 "sensitive input"
+ 20 "success"
+ 30 "temporary redirect"
+ 31 "permanent redirect"
+ 40 "temporary failure"
+ 41 "server unavailable"
+ 42 "CGI error"
+ 43 "proxy error"
+ 44 "slow down"
+ 50 "permanent failure"
+ 51 "not found"
+ 52 "gone"
+ 53 "proxy request refused"
+ 59 "bad request"
+ 60 "client certificate required"
+ 61 "certificate not authorized"
+ 62 "certificate not valid"})
+
+(defn is-input? [{c :code}] (= 1 (/ c 10)))
+(defn is-success? [{c :code}] (= 2 (/ c 10)))
+(defn is-redirect? [{c :code}] (= 3 (/ c 10)))
+(defn is-temporary-failure? [{c :code}] (= 4 (/ c 10)))
+(defn is-permanent-failure? [{c :code}] (= 5 (/ c 10)))
+(defn is-client-cert-required? [{c :code}] (= 6 (/ c 10)))