diff --git a/.DS_Store b/.DS_Store index f4a1254..4cf57ad 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index ac79464..6da317c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/idea \ No newline at end of file +/idea +.DS_Store diff --git a/README.md b/README.md index f737733..6c28e43 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,119 @@ # RawHTTP -HTTP 1.1 server from scratch +A from-scratch HTTP/1.1 server implementation in Go, built directly on TCP sockets. -## HTTP Message Structure +## Project Overview -According to RFC 7230, HTTP messages follow this structure: +RawHTTP is an HTTP/1.1 server implementation that demonstrates low-level network programming concepts by building a complete HTTP server from first principles. Unlike frameworks that abstract away protocol details, this implementation provides direct insight into: + +- Raw TCP socket handling +- HTTP message parsing and generation +- State machine-based request processing +- Buffer management and streaming + +explore how web servers work at the protocol level. + +## HTTP Protocol Deep Dive + +### HTTP Message Structure + +According to RFC 7230, HTTP messages follow this precise structure: ``` start-line CRLF *( field-line CRLF ) -*( field-line CRLF ) -... CRLF [ message-body ] ``` Where: - **start-line**: Request line (method, URI, version) or status line -- **field-line**: HTTP headers (key-value pairs) (The RFC uses the term) -- **CRLF**: Carriage return + line feed (`\r\n`) -- **message-body**: Optional request/response body \ No newline at end of file +- **field-line**: HTTP headers (key-value pairs) +- **CRLF**: Carriage return + line feed (`\r\n`) - critical for protocol compliance +- **message-body**: Optional request/response body + +### Request Message Anatomy + +``` +GET /api/users HTTP/1.1\r\n ← Request Line +Host: example.com\r\n ← Header Field +User-Agent: RawHTTP/1.0\r\n ← Header Field +Accept: application/json\r\n ← Header Field +\r\n ← Header/Body Separator +[optional message body] ← Body (for POST, PUT, etc.) +``` + +#### Request Line Components + +1. **HTTP Method**: Defines the action to be performed + - `GET`: Retrieve data + - `POST`: Submit data + - `PUT`: Update/create resource + - `DELETE`: Remove resource + +2. **Request Target**: Identifies the resource + - **origin-form**: `/path/to/resource?query=value` + - **absolute-form**: `http://example.com/path` + - **authority-form**: `example.com:80` (CONNECT only) + - **asterisk-form**: `*` (OPTIONS only) + +3. **HTTP Version**: Currently `HTTP/1.1` + +### Response Message Anatomy + +``` +HTTP/1.1 200 OK\r\n ← Status Line +Content-Type: text/html\r\n ← Header Field +Content-Length: 1234\r\n ← Header Field +Connection: close\r\n ← Header Field +\r\n ← Header/Body Separator +... ← Body +``` + + +### Header Field Theory + +#### Field Name Constraints +- **tchar**: `!#$%&'*+-.^_`|~` plus ALPHA and DIGIT +- Case-insensitive (normalized to lowercase in our implementation) +- No whitespace between field-name and colon + +#### Field Value Processing +- Leading/trailing whitespace is removed +- Multiple values can be comma-separated + +#### Critical Headers +- **Host**: Required in HTTP/1.1, enables virtual hosting +- **Content-Length**: **Byte count of message body** + +## Running the Server + +### Quick Start +```bash +# Clone the repository +git clone https://github.com/arkahood/RawHTTP.git +cd RawHTTP + +# Run the HTTP server +go run ./cmd/httpserver/main.go + +# Server starts on port 8080 +# Visit http://localhost:8080 in your browser +``` + +### Testing with curl +```bash +# Basic GET request +curl -v http://localhost:8080/ +``` + +### Development Commands +```bash +# Run tests +go test ./... + +# Build binary +go build -o httpserver cmd/httpserver/main.go + +# View test coverage +go test -cover ./... +``` \ No newline at end of file diff --git a/cmd/httpserver/main.go b/cmd/httpserver/main.go new file mode 100644 index 0000000..e72e3d9 --- /dev/null +++ b/cmd/httpserver/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "io" + "log" + "os" + "os/signal" + "syscall" + + "RAWHTTP/internal/request" + "RAWHTTP/pkg/server" +) + +const port = 8080 + +func handler1(w io.Writer, req *request.Request) *server.HandlerError { + // Write a simple response + message := "Hello World!\n" + _, err := w.Write([]byte(message)) + if err != nil { + return &server.HandlerError{ + StatusCode: 500, + Message: "Internal Server Error: failed to write response", + } + } + return nil +} + +func handler2(w io.Writer, req *request.Request) *server.HandlerError { + // Write a simple response + message := "Hello World2!\n" + _, err := w.Write([]byte(message)) + if err != nil { + return &server.HandlerError{ + StatusCode: 500, + Message: "Internal Server Error: failed to write response", + } + } + return nil +} + +func main() { + r := server.NewRouter() + server, err := server.Serve(r, port) + + r.AddRoute("/", handler1) + r.AddRoute("/second-route", handler2) + + if err != nil { + log.Fatalf("Error starting server: %v", err) + } + defer server.Close() + log.Println("Server started on port", port) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + log.Println("Server gracefully stopped") +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 0000000..9644c1c --- /dev/null +++ b/internal/response/response.go @@ -0,0 +1,48 @@ +package response + +import ( + "RAWHTTP/internal/headers" + "errors" + "io" + "strconv" +) + +type StatusCode int + +const ( + StatusCodeOK StatusCode = iota + StatusCodeClientErrors + StatusCodeInternalServerError +) + +func WriteStatusLine(w io.Writer, statusCode StatusCode) error { + var err error + switch statusCode { + case StatusCodeOK: + _, err = w.Write([]byte("HTTP/1.1 200 OK\r\n")) + case StatusCodeClientErrors: + _, err = w.Write([]byte("HTTP/1.1 400 Bad Request\r\n")) + case StatusCodeInternalServerError: + _, err = w.Write([]byte("HTTP/1.1 500 Internal Server Error\r\n")) + default: + return errors.New("unsupported response type") + } + return err +} +func GetDefaultHeaders(contentLen int) headers.Headers { + return headers.Headers{ + "content-length": strconv.Itoa(contentLen), + "content-type": "text/plain", + "connection": "close", + } +} + +func WriteHeaders(w io.Writer, headers headers.Headers) error { + var headerRes []byte + for key, val := range headers { + headerRes = append(headerRes, []byte(key+":"+val+"\r\n")...) + } + headerRes = append(headerRes, []byte("\r\n")...) + _, err := w.Write(headerRes) + return err +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 0000000..c22caa9 --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,158 @@ +package server + +import ( + "bytes" + "errors" + "fmt" + "io" + "net" + "sync/atomic" + + "RAWHTTP/internal/request" + "RAWHTTP/internal/response" +) + +type Server struct { + listener net.Listener + serverIsClosed atomic.Bool + handler *Router +} + +type Router struct { + routes map[string]Handler +} + +func (r *Router) AddRoute(route string, fn Handler) { + r.routes[route] = fn +} + +func NewRouter() *Router { + return &Router{ + routes: make(map[string]Handler), + } +} + +type HandlerError struct { + StatusCode int + Message string +} + +type Handler func(w io.Writer, req *request.Request) *HandlerError + +// WriteHandlerError writes a HandlerError to an io.Writer with proper HTTP response format. +func (hErr *HandlerError) WriteHandlerError(w io.Writer) error { + // Determine the status code + var statusCode response.StatusCode + switch hErr.StatusCode { + case 400: + statusCode = response.StatusCodeClientErrors + case 500: + statusCode = response.StatusCodeInternalServerError + default: + statusCode = response.StatusCodeInternalServerError + } + + // Write status line + err := response.WriteStatusLine(w, statusCode) + if err != nil { + return err + } + + // Prepare error message as body + body := []byte(hErr.Message) + + // Write headers with content length + headers := response.GetDefaultHeaders(len(body)) + err = response.WriteHeaders(w, headers) + if err != nil { + return err + } + + // Write body + _, err = w.Write(body) + return err +} + +func Serve(r *Router, port int) (*Server, error) { + lstnr, err := net.Listen("tcp", fmt.Sprint(":", port)) + if err != nil { + return nil, errors.New("tcp error happened") + } + server := Server{ + listener: lstnr, + handler: r, + } + + go server.listen() + + return &server, nil +} + +func (s *Server) Close() error { + s.serverIsClosed.Store(true) + return s.listener.Close() +} + +func (s *Server) listen() { + for { + conn, err := s.listener.Accept() + if err != nil { + if !s.serverIsClosed.Load() { + fmt.Println("Accept error:", err.Error()) + } + return + } + go s.handle(conn) + } +} + +func (s *Server) handle(conn net.Conn) { + defer conn.Close() + + // Parse the request from the connection + req, err := request.RequestFromReader(conn) + if err != nil { + handlerErr := &HandlerError{ + StatusCode: 400, + Message: "Bad Request: " + err.Error(), + } + handlerErr.WriteHandlerError(conn) + return + } + + // Create a new empty bytes.Buffer for the handler to write to + buf := bytes.NewBuffer([]byte{}) + + // Call the handler function + handler, exists := s.handler.routes[req.RequestLine.RequestTarget] + if !exists { + handlerErr := &HandlerError{ + StatusCode: 404, + Message: "Not Found: " + req.RequestLine.RequestTarget, + } + handlerErr.WriteHandlerError(conn) + return + } + + handlerErr := handler(buf, req) + + // If the handler errors, write the error to the connection + if handlerErr != nil { + handlerErr.WriteHandlerError(conn) + return + } + + // If the handler succeeds: + // Get the response body from the handler's buffer + body := buf.Bytes() + + // Create new default response headers + headers := response.GetDefaultHeaders(len(body)) + + // Write the status line + response.WriteStatusLine(conn, response.StatusCodeOK) + // Write the headers + response.WriteHeaders(conn, headers) + // write the body + conn.Write(body) +}