Initial commit
This commit is contained in:
201
internal/request/request.go
Normal file
201
internal/request/request.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"httpfromtcp/internal/headers"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
RequestLine RequestLine
|
||||
Headers *headers.Headers
|
||||
Body []byte
|
||||
state parserState
|
||||
}
|
||||
|
||||
type RequestLine struct {
|
||||
HttpVersion string
|
||||
RequestTarget string
|
||||
Method string
|
||||
}
|
||||
|
||||
var ERROR_BAD_START_LINE = fmt.Errorf("bad start line")
|
||||
var SEPARATOR = []byte("\r\n")
|
||||
|
||||
type parserState string
|
||||
|
||||
const (
|
||||
StateInit parserState = "init"
|
||||
StateDone parserState = "done"
|
||||
StateHeaders parserState = "headers"
|
||||
StateBody parserState = "body"
|
||||
)
|
||||
|
||||
func getInt(headers *headers.Headers, name string, defaultValue int) int {
|
||||
valueStr, exists := headers.Get(name)
|
||||
if !exists {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(valueStr))
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func newRequest() *Request {
|
||||
return &Request{state: StateInit, Headers: headers.NewHeaders()}
|
||||
}
|
||||
|
||||
func (r *RequestLine) ValidHTTP() bool {
|
||||
return r.HttpVersion == "1.1"
|
||||
}
|
||||
|
||||
func parseRequestLine(b []byte) (*RequestLine, int, error) {
|
||||
idx := bytes.Index(b, SEPARATOR)
|
||||
if idx == -1 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
startLine := b[:idx]
|
||||
read := idx + len(SEPARATOR)
|
||||
|
||||
parts := bytes.Split(startLine, []byte(" "))
|
||||
if len(parts) != 3 {
|
||||
return nil, 0, ERROR_BAD_START_LINE
|
||||
}
|
||||
|
||||
httpParts := bytes.Split(parts[2], []byte("/"))
|
||||
if len(httpParts) != 2 || string(httpParts[0]) != "HTTP" || string(httpParts[1]) != "1.1" {
|
||||
return nil, 0, ERROR_BAD_START_LINE
|
||||
}
|
||||
|
||||
rl := &RequestLine{
|
||||
Method: string(parts[0]),
|
||||
RequestTarget: string(parts[1]),
|
||||
HttpVersion: string(httpParts[1]),
|
||||
}
|
||||
|
||||
if !rl.ValidHTTP() {
|
||||
return nil, 0, ERROR_BAD_START_LINE
|
||||
}
|
||||
|
||||
return rl, read, nil
|
||||
}
|
||||
|
||||
func (r *Request) parse(data []byte) (int, error) {
|
||||
|
||||
read := 0
|
||||
outer:
|
||||
for {
|
||||
currentData := data[read:]
|
||||
if len(currentData) == 0 {
|
||||
break outer
|
||||
}
|
||||
|
||||
switch r.state {
|
||||
case StateInit:
|
||||
rl, n, err := parseRequestLine(currentData)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
break outer
|
||||
}
|
||||
|
||||
r.RequestLine = *rl
|
||||
read += n
|
||||
r.state = StateHeaders
|
||||
|
||||
case StateDone:
|
||||
break outer
|
||||
case StateHeaders:
|
||||
n, done, err := r.Headers.Parse(currentData)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %s", err)
|
||||
return 0, err
|
||||
|
||||
}
|
||||
read += n
|
||||
|
||||
if done {
|
||||
remaining := currentData[n:]
|
||||
if bytes.HasPrefix(remaining, SEPARATOR) {
|
||||
read += len(SEPARATOR)
|
||||
}
|
||||
if getInt(r.Headers, "content-length", 0) == 0 {
|
||||
r.state = StateDone
|
||||
} else {
|
||||
r.state = StateBody
|
||||
}
|
||||
} else if n == 0 {
|
||||
break outer
|
||||
}
|
||||
|
||||
case StateBody:
|
||||
length := getInt(r.Headers, "content-length", 0)
|
||||
if length == 0 {
|
||||
r.state = StateDone
|
||||
break
|
||||
}
|
||||
|
||||
remaining := min(length-len(r.Body), len(currentData))
|
||||
r.Body = append(r.Body, currentData[:remaining]...)
|
||||
read += remaining
|
||||
fmt.Printf("data: %d,%s", read, r.Body)
|
||||
|
||||
if len(r.Body) == length {
|
||||
r.state = StateDone
|
||||
}
|
||||
|
||||
default:
|
||||
panic("code bad")
|
||||
}
|
||||
}
|
||||
return read, nil
|
||||
}
|
||||
|
||||
func (r *Request) done() bool {
|
||||
return r.state == StateDone
|
||||
}
|
||||
|
||||
func RequestFromReader(reader io.Reader) (*Request, error) {
|
||||
request := newRequest()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
bufLen := 0
|
||||
|
||||
for !request.done() {
|
||||
n, err := reader.Read(buf[bufLen:])
|
||||
bufLen += n
|
||||
|
||||
if n > 0 {
|
||||
readN, err := request.parse(buf[:bufLen])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(buf, buf[readN:bufLen])
|
||||
bufLen -= readN
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !request.done() {
|
||||
return nil, fmt.Errorf("Incomplete request")
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
111
internal/request/request_test.go
Normal file
111
internal/request/request_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type chunkReader struct {
|
||||
data string
|
||||
numBytesPerRead int
|
||||
pos int
|
||||
}
|
||||
|
||||
// Read reads up to len(p) or numBytesPerRead bytes from the string per call
|
||||
// its useful for simulating reading a variable number of bytes per chunk from a network connection
|
||||
func (cr *chunkReader) Read(p []byte) (n int, err error) {
|
||||
if cr.pos >= len(cr.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
endIndex := cr.pos + cr.numBytesPerRead
|
||||
if endIndex > len(cr.data) {
|
||||
endIndex = len(cr.data)
|
||||
}
|
||||
n = copy(p, cr.data[cr.pos:endIndex])
|
||||
cr.pos += n
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestRequestLineParse(t *testing.T) {
|
||||
// Test: Good GET Request line
|
||||
reader := &chunkReader{
|
||||
data: "GET / HTTP/1.1\r\nHost: localhost:42069\r\nUser-Agent: curl/7.81.0\r\nAccept: */*\r\n\r\n",
|
||||
numBytesPerRead: 3,
|
||||
}
|
||||
r, err := RequestFromReader(reader)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
assert.Equal(t, "GET", r.RequestLine.Method)
|
||||
assert.Equal(t, "/", r.RequestLine.RequestTarget)
|
||||
assert.Equal(t, "1.1", r.RequestLine.HttpVersion)
|
||||
|
||||
// Test: Good GET Request line with path
|
||||
reader = &chunkReader{
|
||||
data: "GET /coffee HTTP/1.1\r\nHost: localhost:42069\r\nUser-Agent: curl/7.81.0\r\nAccept: */*\r\n\r\n",
|
||||
numBytesPerRead: 1,
|
||||
}
|
||||
r, err = RequestFromReader(reader)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
assert.Equal(t, "GET", r.RequestLine.Method)
|
||||
assert.Equal(t, "/coffee", r.RequestLine.RequestTarget)
|
||||
assert.Equal(t, "1.1", r.RequestLine.HttpVersion)
|
||||
}
|
||||
|
||||
func TestParseHeaders(t *testing.T) {
|
||||
// Test: Standard Headers
|
||||
reader := &chunkReader{
|
||||
data: "GET / HTTP/1.1\r\nHost: localhost:42069\r\nUser-Agent: curl/7.81.0\r\nAccept: */*\r\n\r\n",
|
||||
numBytesPerRead: 3,
|
||||
}
|
||||
r, err := RequestFromReader(reader)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
|
||||
host, _ := r.Headers.Get("host")
|
||||
assert.Equal(t, "localhost:42069", host)
|
||||
user, _ := r.Headers.Get("user-agent")
|
||||
assert.Equal(t, "curl/7.81.0", user)
|
||||
accept, _ := r.Headers.Get("accept")
|
||||
assert.Equal(t, "*/*", accept)
|
||||
|
||||
// Test: Malformed Header
|
||||
reader = &chunkReader{
|
||||
data: "GET / HTTP/1.1\r\nHost localhost:42069\r\n\r\n",
|
||||
numBytesPerRead: 3,
|
||||
}
|
||||
r, err = RequestFromReader(reader)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseBody(t *testing.T) {
|
||||
// Test: Standard Body
|
||||
reader := &chunkReader{
|
||||
data: "POST /submit HTTP/1.1\r\n" +
|
||||
"Host: localhost:42069\r\n" +
|
||||
"Content-Length: 13\r\n" +
|
||||
"\r\n" +
|
||||
"hello world!\n",
|
||||
numBytesPerRead: 3,
|
||||
}
|
||||
r, err := RequestFromReader(reader)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
assert.Equal(t, "hello world!\n", string(r.Body))
|
||||
|
||||
// Test: Body shorter than reported content length
|
||||
reader = &chunkReader{
|
||||
data: "POST /submit HTTP/1.1\r\n" +
|
||||
"Host: localhost:42069\r\n" +
|
||||
"Content-Length: 20\r\n" +
|
||||
"\r\n" +
|
||||
"partial content",
|
||||
numBytesPerRead: 3,
|
||||
}
|
||||
r, err = RequestFromReader(reader)
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user