HTTPステータスの対応
リクエストの検証
前節で、リクエスト内容の読み込みができるようになりました。 これまでは、入力のリクエストが正しいことを前提としたサーバープログラムとなっていました。 リクエストの内容に基づいてエラー処理を加えていきましょう。
正常処理とエラー処理の流れをわかりやすくするために、例外を導入しましょう。
以下のような HTTPException クラスを実装します。
class HTTPException extends Exception {
  String message;
  String[] headers;
  HTTPException(String message) {
    this.message = message;
    this.headers = new String[0];
  }
  HTTPException(String message, String[] headers) {
    this.message = message;
    this.headers = headers;
  }
}
Handler の run メソッド内で以下のように使用します。
正常処理の途中でエラーが発生したら、例外を発生させることで、異常時のレスポンス処理を分離することができます。
    try {
      // 正常処理
      if (/* エラー発生 */) {
        throw new HTTPException("400 Bad Request");
      }
      // 正常処理
    }
    catch (HTTPException e) {
      this.client.write("HTTP/1.1 " + e.message + "\r\n");
      for (int i = 0; i < e.headers.length; ++i) {
        this.client.write(e.headers[i] + "\r\n");
      }
      this.client.write("\r\n");
      this.client.stop();
    }
これを利用して以下のようなエラー処理を加えます。 (本来は、他にも様々なエラー処理が必要です。)
- リクエスト行の形式チェック
- メソッドをGETまたはHEADに限定
- リクエストのパスを /のみに限定
プログラム全体は以下のようになります。
import java.util.Iterator;
import processing.net.*;
Server myServer;
void setup() {
  myServer = new Server(this, 80);
}
void draw() {
}
void serverEvent(Server server, Client client) {
  Handler handler = new Handler(client);
  handler.start();
}
class Handler extends Thread {
  Client client;
  Handler(Client client) {
    this.client = client;
  }
  void run() {
    try {
      HeaderReader reader = new HeaderReader(this.client);
      String[] request = split(reader.next().trim(), ' ');
      if (request.length != 3) {
        throw new HTTPException("400 Bad Request");
      }
      println("Method : " + request[0]);
      println("Path : " + request[1]);
      println("Version : " + request[2]);
      if (!request[0].equals("GET") && !request[0].equals("HEAD")) {
        throw new HTTPException("400 Bad Request");
      }
      if (!request[1].equals("/")) {
        throw new HTTPException("404 Not Found");
      }
      for (String line : reader) {
        String[] header = split(line.trim(), ": ");
        if (header.length == 2) {
          println(header[0] + " : " + header[1]);
        }
      }
      this.client.write("HTTP/1.1 200 OK\r\n");
      this.client.write("\r\n");
      this.client.write("hello\r\n");
      this.client.stop();
    }
    catch (HTTPException e) {
      this.client.write("HTTP/1.1 " + e.message + "\r\n");
      for (int i = 0; i < e.headers.length; ++i) {
        this.client.write(e.headers[i] + "\r\n");
      }
      this.client.write("\r\n");
      this.client.stop();
    }
  }
}
class HeaderReader implements Iterable<String>, Iterator<String> {
  Client client;
  boolean stop = false;
  HeaderReader(Client client) {
    this.client = client;
  }
  Iterator<String> iterator() {
    return this;
  }
  boolean hasNext() {
    return !this.stop;
  }
  String next() {
    while (this.client.active()) {
      if (this.client.available() > 0) {
        String line = this.client.readStringUntil('\n');
        if (line != null) {
          if (line.equals("\r\n")) {
            this.stop = true;
          }
          return line;
        }
      }
    }
    return null;
  }
}
class HTTPException extends Exception {
  String message;
  String[] headers;
  HTTPException(String message) {
    this.message = message;
    this.headers = new String[0];
  }
  HTTPException(String message, String[] headers) {
    this.message = message;
    this.headers = headers;
  }
}
HTTPサーバーに対して異常なリクエストを送信するために、telnetを使用しましょう。
リクエスト行が正常でない場合として「this is not a http request」と送信してみます。
$ telnet 127.0.0.1 80
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
this is not a http request
HTTP/1.1 400 Bad Request
Connection closed by foreign host.
メソッドを「UNSUPPORTED_METHOD」としてみます。
$ telnet 127.0.0.1 80
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
UNSUPPORTED_METHOD / HTTP/1.1
HTTP/1.1 400 Bad Request
Connection closed by foreign host.
パスを「/unknown-resource」としてみます。
$ telnet 127.0.0.1 80
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /unknown-resource HTTP/1.1
HTTP/1.1 404 Not Found
Connection closed by foreign host.
最後に、正常な場合の例です。
$ telnet 127.0.0.1 80
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.1
HTTP/1.1 200 OK
hello
Connection closed by foreign host.
Basic認証
HTTPサーバーの機能の最後の例として、Basic認証 の導入を行います。 Basic認証は、HTTPにおける最もシンプルな認証の仕組みの1つで、リクエスト時にユーザー名とパスワードを送信し、それがサーバー側で設定されているものと一致していれば要求されたリクエストを処理します。
Basic認証では、認証に必要なユーザー名とパスワードを Authorization ヘッダーを使って送ります。 Authorizationヘッダーの形は「Basic 」に続けてユーザー名とパスワードを「:」で繋げて、Base64エンコードした文字列を記します。 ユーザー名が「user」、パスワードが「pass」であれば「user:pass」をBase64エンコードします。
Base64エンコードは、以下のように base64 コマンドで確認することができます。
$ echo some-message | base64
c29tZS1tZXNzYWdlCg==
「user:pass」という文字列をBase64エンコードすると、「dXNlcjpwYXNzCg==」という文字列が得られます。
$ echo user:pass | base64
dXNlcjpwYXNzCg==
「dXNlcjpwYXNzCg==」をデコードすることで、元の「user:pass」という文字列が得られます。
$ echo dXNlcjpwYXNzCg== | base64 -d
user:pass
Basic認証において、ユーザー名「user」パスワード「pass」の認証情報を送信するためには、ヘッダー行に以下のような情報を含めます。
Authorization: Basic dXNlcjpwYXNzCg==
HTTPサーバーは、Authorizationヘッダーを受け取り、設定されたユーザー名とパスワードとの照合を行います。
Processingで、Base64エンコードされた認証情報をデコードし、ユーザー名「user」とパスワード「pass」に合致するかの処理は以下のようになります。
import java.util.Base64;
import java.util.Base64.Decoder;
boolean isValid(String value) {
  Decoder decoder = Base64.getDecoder();
  String auth = new String(decoder.decode(value));
  return auth.equals("user:pass");
}
以下のように、ヘッダーの処理の中でAuthorizationヘッダーを見つけた時に、「Basic 」の後ろのBase64エンコードされた文字列を取り出して認証のチェックを行います。
      boolean authorized = false;
      for (String line : reader) {
        String[] header = split(line.trim(), ": ");
        if (header.length == 2) {
          println(header[0] + " : " + header[1]);
          if (header[0].equals("Authorization")) {
            authorized = isValid(header[1].substring(6));
          }
        }
      }
Authorizationヘッダーが含まれていない場合、またはユーザー名パスワードが異なっている場合には、以下のようにステータスコード401で例外を送出します。
      if (!authorized) {
        String[] headers = { "WWW-Authenticate: Basic" };
        throw new HTTPException("401 Unauthorized", headers);
      }
認証失敗時のレスポンスに WWW-Authenticate ヘッダーを含めておくと、Webブラウザはそれを認識し、ユーザーへ認証情報の入力を求めます。
プログラムの全体は以下のようになります。
import java.util.Iterator;
import java.util.Base64;
import java.util.Base64.Decoder;
import processing.net.*;
Server myServer;
void setup() {
  myServer = new Server(this, 80);
}
void draw() {
}
void serverEvent(Server server, Client client) {
  Handler handler = new Handler(client);
  handler.start();
}
boolean isValid(String value) {
  Decoder decoder = Base64.getDecoder();
  String auth = new String(decoder.decode(value));
  return auth.equals("user:pass");
}
class Handler extends Thread {
  Client client;
  Handler(Client client) {
    this.client = client;
  }
  void run() {
    try {
      HeaderReader reader = new HeaderReader(this.client);
      String[] request = split(reader.next().trim(), ' ');
      if (request.length != 3) {
        throw new HTTPException("400 Bad Request");
      }
      println("Method : " + request[0]);
      println("Path : " + request[1]);
      println("Version : " + request[2]);
      if (!request[0].equals("GET") && !request[0].equals("HEAD")) {
        throw new HTTPException("400 Bad Request");
      }
      if (!request[1].equals("/")) {
        throw new HTTPException("404 Not Found");
      }
      boolean authorized = false;
      for (String line : reader) {
        String[] header = split(line.trim(), ": ");
        if (header.length == 2) {
          println(header[0] + " : " + header[1]);
          if (header[0].equals("Authorization")) {
            authorized = isValid(header[1].substring(6));
          }
        }
      }
      if (!authorized) {
        String[] headers = { "WWW-Authenticate: Basic" };
        throw new HTTPException("401 Unauthorized", headers);
      }
      this.client.write("HTTP/1.1 200 OK\r\n");
      this.client.write("\r\n");
      this.client.write("hello\r\n");
      this.client.stop();
    }
    catch (HTTPException e) {
      this.client.write("HTTP/1.1 " + e.message + "\r\n");
      for (int i = 0; i < e.headers.length; ++i) {
        this.client.write(e.headers[i] + "\r\n");
      }
      this.client.write("\r\n");
      this.client.stop();
    }
  }
}
class HeaderReader implements Iterable<String>, Iterator<String> {
  Client client;
  boolean stop = false;
  HeaderReader(Client client) {
    this.client = client;
  }
  Iterator<String> iterator() {
    return this;
  }
  boolean hasNext() {
    return !this.stop;
  }
  String next() {
    while (this.client.active()) {
      if (this.client.available() > 0) {
        String line = this.client.readStringUntil('\n');
        if (line != null) {
          if (line.equals("\r\n")) {
            this.stop = true;
          }
          return line;
        }
      }
    }
    return null;
  }
}
class HTTPException extends Exception {
  String message;
  String[] headers;
  HTTPException(String message) {
    this.message = message;
    this.headers = new String[0];
  }
  HTTPException(String message, String[] headers) {
    this.message = message;
    this.headers = headers;
  }
}
cURLで動作確認をしてみましょう。
認証情報を含めずにリクエストを行うとステータスコードが401のレスポンスが返ってきます。
$ curl -I http://localhost/
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic
cURLでBasic認証を行うためには --user オプションを使用します。
$ curl --user user:pass http://localhost/
hello
$ curl -I --user user:pass http://localhost/
HTTP/1.1 200 OK
認証情報が正しくない場合には以下のようにステータスコードが401となります。
$ curl -I --user bad:bad http://localhost/
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic
Webブラウザでも http://localhost/ にアクセスし、動作を確認してみましょう。