Index: src/hu/jmk/rtwi/Request.php
===================================================================
--- src/hu/jmk/rtwi/Request.php	(revision 91f444e35f48e6479ee4261ccd8f66ba20dc4073)
+++ src/hu/jmk/rtwi/Request.php	(revision 91f444e35f48e6479ee4261ccd8f66ba20dc4073)
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace hu\jmk\rtwi;
+
+Class Request {
+  const EMPTY = "";
+
+  const AJAX = "ajax";
+
+  const FORMAT = "format";
+  const FORMAT_HTML = "html";
+  const FORMAT_XML = "xml";
+  const FORMATS = [self::FORMAT_HTML, self::FORMAT_XML];
+
+  const HASH = "hash";
+
+  const ID = "id";
+
+  const MOD = "mod";
+  const MOD_ADDTORRENT = "addtorrent";
+  const MOD_GETFILE = "getfile";
+  const MOD_LOGIN = "login";
+  const MOD_SERVERINFO = "serverinfo";
+  const MOD_TORRENT = "torrent";
+  const MOD_TORRENTS = "torrents";
+  const MODS = [self::MOD_ADDTORRENT, self::MOD_GETFILE, self::MOD_LOGIN, self::MOD_SERVERINFO, self::MOD_TORRENT, self::MOD_TORRENTS];
+
+  const PAGE = "page";
+  const PAGE_CHUNKS = "chunks";
+  const PAGE_FILES = "files";
+  const PAGE_INFO = "info";
+  const PAGE_PEERS = "peers";
+  const PAGE_TRACKERS = "trackers";
+  const PAGE_TRANSFERS = "transfers";
+  const PAGES = [self::PAGE_CHUNKS, self::PAGE_FILES, self::PAGE_INFO, self::PAGE_PEERS, self::PAGE_TRACKERS, self::PAGE_TRANSFERS];
+
+  const PATH = "path";
+
+  private bool $ajax;
+  private string $format;
+  private ?string $hash;
+  private int $id;
+  private string $mod;
+  private string $page;
+  private ?string $path;
+
+  public function __construct($request) {
+    $this->ajax = boolval($request[self::AJAX]);
+    $this->format = in_array($request[self::FORMAT], self::FORMATS) ? $request[self::FORMAT] : self::FORMAT_HTML;
+    $this->hash = !is_null($request[self::HASH]) ? $request[self::HASH] : null;
+    $this->id = is_numeric($request[self::ID]) ? intval($request[self::ID]) : -1;
+    $this->setMod($request[self::MOD]);
+    $this->page = in_array($request[self::PAGE], self::PAGES) ? $request[self::PAGE] : self::PAGE_INFO;
+    $this->path = !is_null($request[self::PATH]) ? $request[self::PATH] : null;
+  }
+
+  public function isAjax():bool {
+    return $this->ajax;
+  }
+
+  public function getFormat():string {
+    return $this->format;
+  }
+
+  public function hasHash():bool {
+    return !is_null($this->hash);
+  }
+
+  public function getHash():string {
+    return $this->hash;
+  }
+
+  public function hasId():bool {
+    return $this->id != -1;
+  }
+
+  public function getId():int {
+    return $this->id;
+  }
+
+  public function getMod():string {
+    return $this->mod;
+  }
+
+  public function setMod(?string $mod):string {
+    return $this->mod = in_array($mod, self::MODS) ? $mod : self::MOD_TORRENTS;
+  }
+
+  public function getPage():string {
+    return $this->page;
+  }
+
+  public function hasPath():bool {
+    return !is_null($this->path);
+  }
+
+  public function getPath():String {
+    return $this->path;
+  }
+}
Index: test/hu/jmk/rtwi/RequestTest.php
===================================================================
--- test/hu/jmk/rtwi/RequestTest.php	(revision 91f444e35f48e6479ee4261ccd8f66ba20dc4073)
+++ test/hu/jmk/rtwi/RequestTest.php	(revision 91f444e35f48e6479ee4261ccd8f66ba20dc4073)
@@ -0,0 +1,222 @@
+<?php
+
+declare(strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+use hu\jmk\rtwi\Request;
+
+final class RequestTest extends TestCase {
+  /**
+   * @test
+   */
+  public function shouldGetDefaultAjaxForMissingAjax():void {
+    $this->assertFalse((new Request(array()))->isAjax());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultAjaxForInvalidAjax():void {
+    $this->assertFalse((new Request(array("ajax" => "")))->isAjax());
+    $this->assertFalse((new Request(array("ajax" => "0")))->isAjax());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetAjax():void {
+    $this->assertTrue((new Request(array("ajax" => "1")))->isAjax());
+    $this->assertTrue((new Request(array("ajax" => "meh")))->isAjax());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultFormatForMissingFormat():void {
+    $this->assertSame(Request::FORMAT_HTML, (new Request(array()))->getFormat());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultFormatForInvalidFormat():void {
+    $this->assertSame(Request::FORMAT_HTML, (new Request(array("format" => "meh")))->getFormat());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetFormat():void {
+    $this->assertCount(2, Request::FORMATS);
+    $this->assertSame(Request::FORMAT_HTML, (new Request(array("format" => "html")))->getFormat());
+    $this->assertSame(Request::FORMAT_XML, (new Request(array("format" => "xml")))->getFormat());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldNotHaveHashForMissingHash():void {
+    $this->assertFalse((new Request(array()))->hasHash());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldNotGetHashForMissingHash():void {
+    $this->expectException(TypeError::class);
+    (new Request(array()))->getHash();
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetHash():void {
+    $this->assertSame("0123456789abcdef", (new Request(array("hash" => "0123456789abcdef")))->getHash());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldNotHaveIdForMissingId():void {
+    $this->assertFalse((new Request(array()))->hasId());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultIdForMissingId():void {
+    $this->assertSame(-1, (new Request(array()))->getId());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldNotHaveIdForInvalidId():void {
+    $this->assertFalse((new Request(array("id" => "")))->hasId());
+    $this->assertFalse((new Request(array("id" => "meh")))->hasId());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultIdForInvalidId():void {
+    $this->assertSame(-1, (new Request(array("id" => "")))->getId());
+    $this->assertSame(-1, (new Request(array("id" => "meh")))->getId());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetId():void {
+    $this->assertSame(1, (new Request(array("id" => "1")))->getId());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultModForMissingMod():void {
+    $this->assertSame(Request::MOD_TORRENTS, (new Request(array()))->getMod());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultModForInvalidMod():void {
+    $this->assertSame(Request::MOD_TORRENTS, (new Request(array("mod" => "meh")))->getMod());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetMod():void {
+    $this->assertCount(6, Request::MODS);
+    $this->assertSame(Request::MOD_ADDTORRENT, (new Request(array("mod" => "addtorrent")))->getMod());
+    $this->assertSame(Request::MOD_GETFILE, (new Request(array("mod" => "getfile")))->getMod());
+    $this->assertSame(Request::MOD_LOGIN, (new Request(array("mod" => "login")))->getMod());
+    $this->assertSame(Request::MOD_SERVERINFO, (new Request(array("mod" => "serverinfo")))->getMod());
+    $this->assertSame(Request::MOD_TORRENT, (new Request(array("mod" => "torrent")))->getMod());
+    $this->assertSame(Request::MOD_TORRENTS, (new Request(array("mod" => "torrents")))->getMod());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldNotAllowNullUpdateForMod():void {
+    $request = new Request(array("mod" => "addtorrent"));
+    $this->assertSame(Request::MOD_ADDTORRENT, $request->getMod());
+
+    $request->setMod(null);
+    $this->assertSame(Request::MOD_TORRENTS, $request->getMod());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultModAfterInvalidUpdate():void {
+    $request = new Request(array("mod" => "addtorrent"));
+    $this->assertSame(Request::MOD_ADDTORRENT, $request->getMod());
+
+    $request->setMod("meh");
+    $this->assertSame(Request::MOD_TORRENTS, $request->getMod());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetUpdatedMod():void {
+    $request = new Request(array("mod" => "addtorrent"));
+    $this->assertSame(Request::MOD_ADDTORRENT, $request->getMod());
+
+    $request->setMod(Request::MOD_GETFILE);
+    $this->assertSame(Request::MOD_GETFILE, $request->getMod());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultPageForMissingPage():void {
+    $this->assertSame(Request::PAGE_INFO, (new Request(array()))->getPage());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultPageForInvalidPage():void {
+    $this->assertSame(Request::PAGE_INFO, (new Request(array("page" => "meh")))->getPage());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetPage():void {
+    $this->assertCount(6, Request::PAGES);
+    $this->assertSame(Request::PAGE_CHUNKS, (new Request(array("page" => "chunks")))->getPage());
+    $this->assertSame(Request::PAGE_FILES, (new Request(array("page" => "files")))->getPage());
+    $this->assertSame(Request::PAGE_INFO, (new Request(array("page" => "info")))->getPage());
+    $this->assertSame(Request::PAGE_PEERS, (new Request(array("page" => "peers")))->getPage());
+    $this->assertSame(Request::PAGE_TRACKERS, (new Request(array("page" => "trackers")))->getPage());
+    $this->assertSame(Request::PAGE_TRANSFERS, (new Request(array("page" => "transfers")))->getPage());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldNotHavePathForMissingPath():void {
+    $this->assertFalse((new Request(array()))->hasPath());
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetDefaultPathForMissingPath():void {
+    $this->expectException(TypeError::class);
+    (new Request(array()))->getPath();
+  }
+
+  /**
+   * @test
+   */
+  public function shouldGetPath():void {
+    $this->assertSame("some/path", (new Request(array("path" => "some/path")))->getPath());
+  }
+}
