Skip to content

Commit b74f0f9

Browse files
committed
request line and http headers parsing completed
1 parent d1a7ce4 commit b74f0f9

File tree

10 files changed

+450
-54
lines changed

10 files changed

+450
-54
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
run:
2-
go run ./cmd/tcplistener/
2+
go run ./cmd/tcplistener/ | tee tcplistener.log

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,21 @@
11
# RawHTTP
22
HTTP 1.1 server from scratch
3+
4+
## HTTP Message Structure
5+
6+
According to RFC 7230, HTTP messages follow this structure:
7+
8+
```
9+
start-line CRLF
10+
*( field-line CRLF )
11+
*( field-line CRLF )
12+
...
13+
CRLF
14+
[ message-body ]
15+
```
16+
17+
Where:
18+
- **start-line**: Request line (method, URI, version) or status line
19+
- **field-line**: HTTP headers (key-value pairs) (The RFC uses the term)
20+
- **CRLF**: Carriage return + line feed (`\r\n`)
21+
- **message-body**: Optional request/response body

cmd/tcplistener/main.go

Lines changed: 13 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,11 @@
11
package main
22

33
import (
4+
"RAWHTTP/internal/request"
45
"fmt"
5-
"io"
66
"net"
7-
"strings"
87
)
98

10-
func getLinesChannel(f io.ReadCloser) <-chan string {
11-
lines := make(chan string)
12-
13-
go func() {
14-
defer f.Close()
15-
defer close(lines)
16-
17-
currentLine := ""
18-
19-
for {
20-
read := make([]byte, 8)
21-
_, err := f.Read(read)
22-
23-
if err == io.EOF {
24-
// Send the last line if it has content
25-
if currentLine != "" {
26-
lines <- currentLine
27-
}
28-
return
29-
}
30-
31-
if err != nil {
32-
fmt.Println("Some Error Happened While Putting in Slice")
33-
return
34-
}
35-
36-
parts := strings.Split(string(read), "\n")
37-
38-
// Process all parts except the last one, which may be incomplete
39-
if len(parts)-1 > 0 {
40-
currentLine += parts[0]
41-
lines <- currentLine
42-
currentLine = "" // Reset for next line
43-
}
44-
45-
// The last part, which may be incomplete gets added to currentLine
46-
currentLine += parts[len(parts)-1]
47-
}
48-
}()
49-
50-
return lines
51-
}
52-
539
func main() {
5410
listener, err := net.Listen("tcp", ":42069")
5511

@@ -66,10 +22,19 @@ func main() {
6622
}
6723

6824
go func(c net.Conn) {
69-
lines := getLinesChannel(conn)
25+
req, err := request.RequestFromReader(conn)
26+
27+
if err != nil {
28+
fmt.Println("error happened", err.Error())
29+
}
7030

71-
for line := range lines {
72-
fmt.Println(line)
31+
fmt.Println("Request line:")
32+
fmt.Println("Method: ", req.RequestLine.Method)
33+
fmt.Println("Http Version: ", req.RequestLine.HttpVersion)
34+
fmt.Println("Target: ", req.RequestLine.RequestTarget)
35+
fmt.Println("Headers")
36+
for key, val := range req.Headers {
37+
fmt.Println(key, ": ", val)
7338
}
7439
}(conn)
7540
}

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
module RAWHTTP
22

33
go 1.25.3
4+
5+
require (
6+
github.com/davecgh/go-spew v1.1.1 // indirect
7+
github.com/pmezard/go-difflib v1.0.0 // indirect
8+
github.com/stretchr/testify v1.11.1 // indirect
9+
gopkg.in/yaml.v3 v3.0.1 // indirect
10+
)

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
7+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
8+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
9+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/headers/headers.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package headers
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"unicode"
7+
)
8+
9+
const FieldLineSeperator = ":"
10+
const crlf = "\r\n"
11+
12+
type Headers map[string]string
13+
14+
// there can be an unlimited amount of whitespace
15+
// before and after the field-value (Header value). However, when parsing a field-name,
16+
// there must be no spaces betwixt the colon and the field-name. In other words,
17+
// these are valid:
18+
19+
// 'Host: localhost:42069'
20+
// ' Host: localhost:42069 '
21+
22+
// But this is not:
23+
24+
// Host : localhost:42069
25+
26+
func (h Headers) Parse(data []byte) (n int, done bool, err error) {
27+
endIdx := strings.Index(string(data), crlf)
28+
if endIdx == -1 {
29+
return 0, false, nil
30+
}
31+
// if encounter \r\n in front of line that means we consumed all the headers/fieldLines
32+
if endIdx == 0 {
33+
return endIdx + 2, true, nil
34+
}
35+
36+
currentLine := string(data[:endIdx])
37+
fieldParts := strings.SplitN(currentLine, FieldLineSeperator, 2)
38+
39+
if len(fieldParts) != 2 {
40+
return 0, false, errors.New("field-line have wrong number of parts")
41+
}
42+
43+
fieldName := strings.TrimLeft(fieldParts[0], " ")
44+
if len(fieldName) != len(strings.TrimSpace(fieldName)) {
45+
return 0, false, errors.New("error in field-name syntax, whitespace unexpected")
46+
}
47+
if !isValidFieldName(fieldName) {
48+
return 0, false, errors.New("invalid characters in field-name")
49+
}
50+
51+
fieldValue := strings.TrimSpace(strings.Trim(fieldParts[1], crlf))
52+
// lowercase the fieldname while adding to the map
53+
val, exists := h[strings.ToLower(fieldName)]
54+
if exists {
55+
h[strings.ToLower(fieldName)] = val + "," + fieldValue
56+
} else {
57+
h[strings.ToLower(fieldName)] = fieldValue
58+
}
59+
60+
return endIdx + 2, false, nil
61+
}
62+
63+
func isValidFieldName(fieldName string) bool {
64+
allowedSpecials := map[rune]bool{
65+
'!': true, '#': true, '$': true, '%': true, '&': true, '\'': true,
66+
'*': true, '+': true, '-': true, '.': true, '^': true, '_': true,
67+
'`': true, '|': true, '~': true,
68+
}
69+
70+
if len(fieldName) == 0 {
71+
return false
72+
}
73+
74+
for _, ch := range fieldName {
75+
switch {
76+
case unicode.IsLetter(ch):
77+
continue
78+
case unicode.IsDigit(ch):
79+
continue
80+
case allowedSpecials[ch]:
81+
continue
82+
default:
83+
return false
84+
}
85+
}
86+
87+
return true
88+
}

internal/headers/headers_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package headers
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestHeadersParser(t *testing.T) {
11+
// Test: Valid single header
12+
headers := make(Headers)
13+
data := []byte("host: localhost:42069\r\n\r\n")
14+
n, done, err := headers.Parse(data)
15+
require.NoError(t, err)
16+
require.NotNil(t, headers)
17+
assert.Equal(t, "localhost:42069", headers["host"])
18+
assert.Equal(t, 23, n)
19+
assert.False(t, done)
20+
21+
// Test: Invalid spacing header
22+
headers = make(Headers)
23+
data = []byte(" Host : localhost:42069 \r\n\r\n")
24+
n, done, err = headers.Parse(data)
25+
require.Error(t, err)
26+
assert.Equal(t, 0, n)
27+
assert.False(t, done)
28+
29+
// Test: Invalid character in header
30+
headers = make(Headers)
31+
data = []byte(" H(st : localhost:42069 \r\n\r\n")
32+
n, done, err = headers.Parse(data)
33+
require.Error(t, err)
34+
assert.Equal(t, 0, n)
35+
assert.False(t, done)
36+
37+
// Test: Uppercase FieldName should add as a lowercase key
38+
headers = make(Headers)
39+
data = []byte("Host: localhost:42069\r\n\r\n")
40+
n, done, err = headers.Parse(data)
41+
require.NoError(t, err)
42+
require.NotNil(t, headers)
43+
assert.Equal(t, "localhost:42069", headers["host"])
44+
assert.Equal(t, 23, n)
45+
assert.False(t, done)
46+
47+
// Test: multipe same fieldname should have values comma separated
48+
headers = make(Headers)
49+
data = []byte("Host: localhost:42069\r\nHost: localhost:42070\r\n\r\n")
50+
bytesConsumed0, _, _ := headers.Parse(data)
51+
bytesConsumed1, done, err := headers.Parse(data[bytesConsumed0:])
52+
require.NoError(t, err)
53+
require.NotNil(t, headers)
54+
assert.Equal(t, "localhost:42069,localhost:42070", headers["host"])
55+
assert.Equal(t, bytesConsumed0+bytesConsumed1, len(data)-2)
56+
assert.False(t, done)
57+
}

0 commit comments

Comments
 (0)