diff --git a/capability/route.go b/capability/route.go index 2fe977d..f2ecb31 100644 --- a/capability/route.go +++ b/capability/route.go @@ -9,7 +9,7 @@ import ( func NewRoute() *ServerCapabilityRoute { controller := &StaticCapabilityServer{ - AvailableMethods: []string{"GET", "HEAD"}, + AvailableMethods: []string{http.GET, http.HEAD}, } return &ServerCapabilityRoute{Controller: controller} @@ -19,11 +19,11 @@ type ServerCapabilityRoute struct { Controller ServerResource } -func (route *ServerCapabilityRoute) Route(requested *http.RequestLine) http.Request { - if requested.Target != "*" { +func (route *ServerCapabilityRoute) Route(requested http.RequestMessage) http.Request { + if requested.Target() != "*" { return nil - } else if requested.Method != "OPTIONS" { - return clienterror.MethodNotAllowed("OPTIONS") + } else if requested.Method() != http.OPTIONS { + return clienterror.MethodNotAllowed(http.OPTIONS) } return &optionsRequest{Resource: route.Controller} diff --git a/capability/route_test.go b/capability/route_test.go index 51f1e34..02c746f 100644 --- a/capability/route_test.go +++ b/capability/route_test.go @@ -20,7 +20,7 @@ var _ = Describe("::NewRoute", func() { route := capability.NewRoute() Expect(route.Controller).To(BeEquivalentTo( &capability.StaticCapabilityServer{ - AvailableMethods: []string{"GET", "HEAD"}, + AvailableMethods: []string{http.GET, http.HEAD}, }, )) }) @@ -31,7 +31,7 @@ var _ = Describe("ServerCapabilityRoute", func() { var ( router http.Route controller *ServerCapabilityServerMock - requested *http.RequestLine + requested http.RequestMessage routedRequest http.Request ) @@ -40,23 +40,23 @@ var _ = Describe("ServerCapabilityRoute", func() { router = &capability.ServerCapabilityRoute{Controller: controller} }) - Context("when the target is *", func() { + Context("when the path is *", func() { It("routes OPTIONS to ServerResource", func() { - requested = &http.RequestLine{Method: "OPTIONS", Target: "*"} + requested = http.NewOptionsMessage("*") routedRequest = router.Route(requested) routedRequest.Handle(&bufio.Writer{}) controller.OptionsShouldHaveBeenCalled() }) It("returns MethodNotAllowed for any other method", func() { - requested = &http.RequestLine{Method: "GET", Target: "*"} + requested = http.NewGetMessage("*") routedRequest = router.Route(requested) - Expect(routedRequest).To(BeEquivalentTo(clienterror.MethodNotAllowed("OPTIONS"))) + Expect(routedRequest).To(BeEquivalentTo(clienterror.MethodNotAllowed(http.OPTIONS))) }) }) - It("returns nil to pass on any other target", func() { - requested = &http.RequestLine{Method: "OPTIONS", Target: "/"} + It("returns nil to pass on any other path", func() { + requested = http.NewOptionsMessage("/") routedRequest = router.Route(requested) Expect(routedRequest).To(BeNil()) }) diff --git a/capability/server_capability.go b/capability/server_capability.go index 87cd5e5..7efe2aa 100644 --- a/capability/server_capability.go +++ b/capability/server_capability.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/kkrull/gohttp/msg" + "github.com/kkrull/gohttp/msg/success" ) // Reports on server capabilities that are defined during startup and do not change after that @@ -13,7 +14,7 @@ type StaticCapabilityServer struct { } func (controller *StaticCapabilityServer) Options(client io.Writer) { - msg.WriteStatusLine(client, 200, "OK") + msg.WriteStatus(client, success.OKStatus) msg.WriteHeader(client, "Allow", strings.Join(controller.AvailableMethods, ",")) msg.WriteContentLengthHeader(client, 0) msg.WriteEndOfMessageHeader(client) diff --git a/capability/server_capability_test.go b/capability/server_capability_test.go index 76dd885..7f1bd9b 100644 --- a/capability/server_capability_test.go +++ b/capability/server_capability_test.go @@ -4,6 +4,7 @@ import ( "bytes" "github.com/kkrull/gohttp/capability" + "github.com/kkrull/gohttp/http" "github.com/kkrull/gohttp/httptest" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -20,7 +21,7 @@ var _ = Describe("StaticCapabilityServer", func() { BeforeEach(func() { responseBuffer = &bytes.Buffer{} controller = &capability.StaticCapabilityServer{ - AvailableMethods: []string{"CONNECT", "TRACE"}, + AvailableMethods: []string{http.CONNECT, http.TRACE}, } controller.Options(responseBuffer) response = httptest.ParseResponse(responseBuffer) @@ -40,14 +41,14 @@ var _ = Describe("StaticCapabilityServer", func() { BeforeEach(func() { responseBuffer = &bytes.Buffer{} controller = &capability.StaticCapabilityServer{ - AvailableMethods: []string{"OPTIONS"}, + AvailableMethods: []string{http.OPTIONS}, } controller.Options(responseBuffer) response = httptest.ParseResponse(responseBuffer) }) It("sets Allow to that one method", func() { - response.HeaderShould("Allow", Equal("OPTIONS")) + response.HeaderShould("Allow", Equal(http.OPTIONS)) }) }) @@ -55,7 +56,7 @@ var _ = Describe("StaticCapabilityServer", func() { BeforeEach(func() { responseBuffer = &bytes.Buffer{} controller = &capability.StaticCapabilityServer{ - AvailableMethods: []string{"CONNECT", "TRACE"}, + AvailableMethods: []string{http.CONNECT, http.TRACE}, } controller.Options(responseBuffer) response = httptest.ParseResponse(responseBuffer) diff --git a/fs/directory_listing.go b/fs/directory_listing.go index 0b77d4d..a0804a2 100644 --- a/fs/directory_listing.go +++ b/fs/directory_listing.go @@ -7,6 +7,7 @@ import ( "path" "github.com/kkrull/gohttp/msg" + "github.com/kkrull/gohttp/msg/success" ) type DirectoryListing struct { @@ -22,7 +23,7 @@ func (listing *DirectoryListing) WriteTo(client io.Writer) error { } func (listing *DirectoryListing) WriteHeader(client io.Writer) error { - msg.WriteStatusLine(client, 200, "OK") + msg.WriteStatus(client, success.OKStatus) msg.WriteContentTypeHeader(client, "text/html") listing.body = listing.messageListingFiles() diff --git a/fs/file_contents.go b/fs/file_contents.go index ce010ce..0659c12 100644 --- a/fs/file_contents.go +++ b/fs/file_contents.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/kkrull/gohttp/msg" + "github.com/kkrull/gohttp/msg/success" ) type FileContents struct { @@ -21,7 +22,7 @@ func (contents *FileContents) WriteTo(client io.Writer) error { } func (contents *FileContents) WriteHeader(client io.Writer) error { - msg.WriteStatusLine(client, 200, "OK") + msg.WriteStatus(client, success.OKStatus) contents.writeHeadersDescribingFile(client) msg.WriteEndOfMessageHeader(client) return nil diff --git a/fs/fs_suite_test.go b/fs/fs_suite_test.go index 4dcbcd9..8473895 100644 --- a/fs/fs_suite_test.go +++ b/fs/fs_suite_test.go @@ -4,6 +4,7 @@ import ( "io" "testing" + "github.com/kkrull/gohttp/http" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) @@ -14,7 +15,7 @@ func TestFs(t *testing.T) { } type FileSystemResourceMock struct { - getTarget string + getPath string headTarget string } @@ -22,12 +23,12 @@ func (mock *FileSystemResourceMock) Name() string { return "File system mock" } -func (mock *FileSystemResourceMock) Get(client io.Writer, target string) { - mock.getTarget = target +func (mock *FileSystemResourceMock) Get(client io.Writer, req http.RequestMessage) { + mock.getPath = req.Path() } -func (mock *FileSystemResourceMock) GetShouldHaveReceived(target string) { - ExpectWithOffset(1, mock.getTarget).To(Equal(target)) +func (mock *FileSystemResourceMock) GetShouldHaveReceived(path string) { + ExpectWithOffset(1, mock.getPath).To(Equal(path)) } func (mock *FileSystemResourceMock) Head(client io.Writer, target string) { diff --git a/fs/requests.go b/fs/requests.go index 4cca831..ea6a02e 100644 --- a/fs/requests.go +++ b/fs/requests.go @@ -18,8 +18,8 @@ func (controller *ReadOnlyFileSystem) Name() string { return "Readonly file system" } -func (controller *ReadOnlyFileSystem) Get(client io.Writer, target string) { - response := controller.determineResponse(target) +func (controller *ReadOnlyFileSystem) Get(client io.Writer, req http.RequestMessage) { + response := controller.determineResponse(req.Path()) response.WriteTo(client) } @@ -28,18 +28,18 @@ func (controller *ReadOnlyFileSystem) Head(client io.Writer, target string) { response.WriteHeader(client) } -func (controller *ReadOnlyFileSystem) determineResponse(requestedTarget string) http.Response { - resolvedTarget := path.Join(controller.BaseDirectory, requestedTarget) - info, err := os.Stat(resolvedTarget) +func (controller *ReadOnlyFileSystem) determineResponse(requestedPath string) http.Response { + resolvedPath := path.Join(controller.BaseDirectory, requestedPath) + info, err := os.Stat(resolvedPath) if err != nil { - return &clienterror.NotFound{Target: requestedTarget} + return &clienterror.NotFound{Path: requestedPath} } else if info.IsDir() { - files, _ := ioutil.ReadDir(resolvedTarget) + files, _ := ioutil.ReadDir(resolvedPath) return &DirectoryListing{ Files: readFileNames(files), - HrefPrefix: requestedTarget} + HrefPrefix: requestedPath} } else { - return &FileContents{Filename: resolvedTarget} + return &FileContents{Filename: resolvedPath} } } diff --git a/fs/requests_test.go b/fs/requests_test.go index 80beb97..9eda539 100644 --- a/fs/requests_test.go +++ b/fs/requests_test.go @@ -7,6 +7,7 @@ import ( "path" "github.com/kkrull/gohttp/fs" + "github.com/kkrull/gohttp/http" "github.com/kkrull/gohttp/httptest" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -28,9 +29,9 @@ var _ = Describe("ReadOnlyFileSystem", func() { }) Describe("#Get", func() { - Context("when the resolved target does not exist", func() { + Context("when the resolved path does not exist", func() { BeforeEach(func() { - controller.Get(responseBuffer, "/missing.txt") + controller.Get(responseBuffer, http.NewGetMessage("/missing.txt")) response = httptest.ParseResponse(responseBuffer) }) @@ -48,11 +49,11 @@ var _ = Describe("ReadOnlyFileSystem", func() { }) }) - Context("when the target is a readable text file in the base path", func() { + Context("when the path is a readable text file in the base path", func() { BeforeEach(func() { existingFile := path.Join(basePath, "readable.txt") Expect(createTextFile(existingFile, "A")).To(Succeed()) - controller.Get(responseBuffer, "/readable.txt") + controller.Get(responseBuffer, http.NewGetMessage("/readable.txt")) response = httptest.ParseResponse(responseBuffer) }) @@ -70,40 +71,40 @@ var _ = Describe("ReadOnlyFileSystem", func() { }) }) - Context("when the target is a readable file named with a registered extension", func() { + Context("when the path is a readable file named with a registered extension", func() { BeforeEach(func() { existingFile := path.Join(basePath, "image.jpeg") Expect(createTextFile(existingFile, "A")).To(Succeed()) }) It("sets Content-Type to the MIME type registered for that extension", func() { - controller.Get(responseBuffer, "/image.jpeg") + controller.Get(responseBuffer, http.NewGetMessage("/image.jpeg")) response = httptest.ParseResponse(responseBuffer) response.HeaderShould("Content-Type", Equal("image/jpeg")) }) }) - Context("when the target is a readable file without an extension", func() { + Context("when the path is a readable file without an extension", func() { BeforeEach(func() { existingFile := path.Join(basePath, "assumed-text") Expect(createTextFile(existingFile, "A")).To(Succeed()) }) It("sets Content-Type to text/plain", func() { - controller.Get(responseBuffer, "/assumed-text") + controller.Get(responseBuffer, http.NewGetMessage("/assumed-text")) response = httptest.ParseResponse(responseBuffer) response.HeaderShould("Content-Type", Equal("text/plain")) }) }) - Context("when the target is /", func() { + Context("when the path is /", func() { BeforeEach(func() { existingFile := path.Join(basePath, "one") Expect(createTextFile(existingFile, "1")).To(Succeed()) }) It("responds with 200 OK", func() { - controller.Get(responseBuffer, "/") + controller.Get(responseBuffer, http.NewGetMessage("/")) response = httptest.ParseResponse(responseBuffer) response.StatusShouldBe(200, "OK") }) @@ -117,7 +118,7 @@ var _ = Describe("ReadOnlyFileSystem", func() { ) BeforeEach(func() { - controller.Get(getResponseBuffer, "/missing.txt") + controller.Get(getResponseBuffer, http.NewGetMessage("/missing.txt")) getResponse = httptest.ParseResponse(getResponseBuffer) controller.Head(responseBuffer, "/missing.txt") diff --git a/fs/route.go b/fs/route.go index fa40ef3..524e837 100644 --- a/fs/route.go +++ b/fs/route.go @@ -18,13 +18,13 @@ type FileSystemRoute struct { Resource FileSystemResource } -func (route FileSystemRoute) Route(requested *http.RequestLine) http.Request { - return http.MakeResourceRequest(requested, route.Resource) +func (route FileSystemRoute) Route(requested http.RequestMessage) http.Request { + return requested.MakeResourceRequest(route.Resource) } // Represents files and directories on the file system type FileSystemResource interface { Name() string - Get(client io.Writer, target string) + Get(client io.Writer, req http.RequestMessage) Head(client io.Writer, target string) } diff --git a/fs/route_test.go b/fs/route_test.go index 3886e11..213068f 100644 --- a/fs/route_test.go +++ b/fs/route_test.go @@ -38,23 +38,23 @@ var _ = Describe("FileSystemRoute", func() { Describe("#Route", func() { It("routes GET requests to GetRequest", func() { - requested := &http.RequestLine{Method: "GET", Target: "/foo"} + requested := http.NewGetMessage("/foo") routedRequest := route.Route(requested) routedRequest.Handle(response) resource.GetShouldHaveReceived("/foo") }) It("routes HEAD requests to HeadRequest", func() { - requested := &http.RequestLine{Method: "HEAD", Target: "/foo"} + requested := http.NewHeadMessage("/foo") routedRequest := route.Route(requested) routedRequest.Handle(response) resource.HeadShouldHaveReceived("/foo") }) It("routes any other method to MethodNotAllowed", func() { - requested := &http.RequestLine{Method: "TRACE", Target: "/"} + requested := http.NewTraceMessage("/") routedRequest := route.Route(requested) - Expect(routedRequest).To(BeEquivalentTo(clienterror.MethodNotAllowed("GET", "HEAD", "OPTIONS"))) + Expect(routedRequest).To(BeEquivalentTo(clienterror.MethodNotAllowed(http.GET, http.HEAD, http.OPTIONS))) }) }) }) diff --git a/http/messages.go b/http/messages.go new file mode 100644 index 0000000..77e91e0 --- /dev/null +++ b/http/messages.go @@ -0,0 +1,161 @@ +package http + +import ( + "sort" + + "github.com/kkrull/gohttp/msg/clienterror" + "github.com/kkrull/gohttp/msg/servererror" +) + +const ( + CONNECT string = "CONNECT" + GET string = "GET" + HEAD string = "HEAD" + OPTIONS string = "OPTIONS" + POST string = "POST" + PUT string = "PUT" + TRACE string = "TRACE" +) + +func NewGetMessage(path string) RequestMessage { + return &requestMessage{ + method: GET, + target: path, + path: path, + } +} + +func NewHeadMessage(path string) RequestMessage { + return &requestMessage{ + method: HEAD, + target: path, + path: path, + } +} + +// Creates an OPTIONS request to the specified target, which can either be a path starting with / +// or an asterisk-form query of the server as a whole (https://tools.ietf.org/html/rfc7230#section-5.3.4). +func NewOptionsMessage(targetAsteriskOrPath string) RequestMessage { + return &requestMessage{ + method: OPTIONS, + target: targetAsteriskOrPath, + path: targetAsteriskOrPath, + } +} + +func NewPutMessage(path string) RequestMessage { + return &requestMessage{ + method: PUT, + target: path, + path: path, + } +} + +func NewTraceMessage(path string) RequestMessage { + return &requestMessage{ + method: TRACE, + target: path, + path: path, + } +} + +func NewRequestMessage(method, path string) RequestMessage { + return &requestMessage{ + method: method, + target: path, + path: path, + } +} + +type requestMessage struct { + method string + path string + target string + queryParameters []QueryParameter +} + +func (message *requestMessage) Method() string { + return message.method +} + +func (message *requestMessage) Path() string { + return message.path +} + +func (message *requestMessage) AddQueryFlag(name string) { + message.queryParameters = append(message.queryParameters, QueryParameter{Name: name}) +} + +func (message *requestMessage) AddQueryParameter(name, value string) { + message.queryParameters = append(message.queryParameters, QueryParameter{Name: name, Value: value}) +} + +func (message *requestMessage) QueryParameters() []QueryParameter { + return message.queryParameters +} + +func (message *requestMessage) Target() string { + return message.target +} + +func (message *requestMessage) NotImplemented() Response { + return &servererror.NotImplemented{Method: message.method} +} + +func (message *requestMessage) MakeResourceRequest(resource Resource) Request { + if message.method == OPTIONS { + return &optionsRequest{ + SupportedMethods: message.supportedMethods(resource), + } + } + + method := knownMethods[message.method] + if method == nil { + return message.unknownHttpMethod(resource) + } + + request, isSupported := method.MakeRequest(message, resource) + if !isSupported { + return message.unsupportedMethod(resource) + } + + return request +} + +func (message *requestMessage) unknownHttpMethod(resource Resource) Request { + return clienterror.MethodNotAllowed(message.supportedMethods(resource)...) +} + +func (message *requestMessage) unsupportedMethod(resource Resource) Request { + return clienterror.MethodNotAllowed(message.supportedMethods(resource)...) +} + +func (message *requestMessage) supportedMethods(resource Resource) []string { + supported := []string{OPTIONS} + for name, method := range knownMethods { + imaginaryRequest := &requestMessage{method: name, target: message.target} + _, isSupported := method.MakeRequest(imaginaryRequest, resource) + if isSupported { + supported = append(supported, name) + } + } + + sort.Strings(supported) + return supported +} + +var knownMethods = map[string]Method{ + GET: &getMethod{}, + HEAD: &headMethod{}, + POST: &postMethod{}, + PUT: &putMethod{}, +} + +type Method interface { + MakeRequest(requested *requestMessage, resource Resource) (request Request, isSupported bool) +} + +// Handles requests of supported HTTP methods for a resource +type Resource interface { + Name() string +} diff --git a/http/method_dispatcher.go b/http/method_dispatcher.go deleted file mode 100644 index 615fd84..0000000 --- a/http/method_dispatcher.go +++ /dev/null @@ -1,65 +0,0 @@ -package http - -import ( - "sort" - - "github.com/kkrull/gohttp/msg/clienterror" -) - -func MakeResourceRequest(requested *RequestLine, resource Resource) Request { - if requested.Method == "OPTIONS" { - return &optionsRequest{ - SupportedMethods: supportedMethods(requested.Target, resource), - } - } - - method := knownMethods[requested.Method] - if method == nil { - return unknownHttpMethod(requested, resource) - } - - request := method.MakeRequest(requested, resource) - if request == nil { - return unsupportedMethod(requested, resource) - } - - return request -} - -func unknownHttpMethod(requested *RequestLine, resource Resource) Request { - return clienterror.MethodNotAllowed(supportedMethods(requested.Target, resource)...) -} - -func unsupportedMethod(requested *RequestLine, resource Resource) Request { - return clienterror.MethodNotAllowed(supportedMethods(requested.Target, resource)...) -} - -func supportedMethods(target string, resource Resource) []string { - supported := []string{"OPTIONS"} - for name, method := range knownMethods { - imaginaryRequest := &RequestLine{Method: name, Target: target} - request := method.MakeRequest(imaginaryRequest, resource) - if request != nil { - supported = append(supported, name) - } - } - - sort.Strings(supported) - return supported -} - -var knownMethods = map[string]Method{ - "GET": &getMethod{}, - "HEAD": &headMethod{}, - "POST": &postMethod{}, - "PUT": &putMethod{}, -} - -type Method interface { - MakeRequest(requested *RequestLine, resource Resource) Request -} - -// Handles requests of supported HTTP methods for a resource -type Resource interface { - Name() string -} diff --git a/http/methods.go b/http/methods.go index 27013df..2457599 100644 --- a/http/methods.go +++ b/http/methods.go @@ -5,46 +5,53 @@ import ( "strings" "github.com/kkrull/gohttp/msg" + "github.com/kkrull/gohttp/msg/success" ) /* GET */ type getMethod struct{} -func (method *getMethod) MakeRequest(requested *RequestLine, resource Resource) Request { +func (method *getMethod) MakeRequest(requested *requestMessage, resource Resource) (request Request, isSupported bool) { supportedResource, ok := resource.(GetResource) if ok { - return &getRequest{Resource: supportedResource, Target: requested.Target} + return &getRequest{ + Message: requested, + Resource: supportedResource, + }, true } - return nil + return nil, false } type getRequest struct { + Message RequestMessage Resource GetResource - Target string } func (request *getRequest) Handle(client io.Writer) error { - request.Resource.Get(client, request.Target) + request.Resource.Get(client, request.Message) return nil } type GetResource interface { - Get(client io.Writer, target string) + Get(client io.Writer, req RequestMessage) } /* HEAD */ type headMethod struct{} -func (*headMethod) MakeRequest(requested *RequestLine, resource Resource) Request { +func (*headMethod) MakeRequest(requested *requestMessage, resource Resource) (request Request, isSupported bool) { supportedResource, ok := resource.(HeadResource) if ok { - return &headRequest{Resource: supportedResource, Target: requested.Target} + return &headRequest{ + Resource: supportedResource, + Target: requested.target, + }, true } - return nil + return nil, false } type headRequest struct { @@ -68,7 +75,7 @@ type optionsRequest struct { } func (request *optionsRequest) Handle(client io.Writer) error { - msg.WriteStatusLine(client, 200, "OK") + msg.WriteStatus(client, success.OKStatus) msg.WriteContentLengthHeader(client, 0) msg.WriteHeader(client, "Allow", strings.Join(request.SupportedMethods, ",")) msg.WriteEndOfMessageHeader(client) @@ -79,13 +86,16 @@ func (request *optionsRequest) Handle(client io.Writer) error { type postMethod struct{} -func (*postMethod) MakeRequest(requested *RequestLine, resource Resource) Request { +func (*postMethod) MakeRequest(requested *requestMessage, resource Resource) (request Request, isSupported bool) { supportedResource, ok := resource.(PostResource) if ok { - return &postRequest{Resource: supportedResource, Target: requested.Target} + return &postRequest{ + Resource: supportedResource, + Target: requested.target, + }, true } - return nil + return nil, false } type postRequest struct { @@ -106,13 +116,16 @@ type PostResource interface { type putMethod struct{} -func (*putMethod) MakeRequest(requested *RequestLine, resource Resource) Request { +func (*putMethod) MakeRequest(requested *requestMessage, resource Resource) (request Request, isSupported bool) { supportedResource, ok := resource.(PutResource) if ok { - return &putRequest{Resource: supportedResource, Target: requested.Target} + return &putRequest{ + Resource: supportedResource, + Target: requested.target, + }, true } - return nil + return nil, false } type putRequest struct { diff --git a/http/parser.go b/http/parser.go index e621b02..23341e2 100644 --- a/http/parser.go +++ b/http/parser.go @@ -10,63 +10,74 @@ import ( // Parses an HTTP request message one line at a time. type LineRequestParser struct{} -func (parser *LineRequestParser) Parse(reader *bufio.Reader) (ok *RequestLine, err Response) { +func (parser *LineRequestParser) Parse(reader *bufio.Reader) (ok *requestMessage, err Response) { methodObject := &parseMethodObject{reader: reader} - return methodObject.Parse() + return methodObject.ReadingRequestLine() } +//A state machine that parses an HTTP request during the process of reading the request from input type parseMethodObject struct { reader *bufio.Reader } -func (parser *parseMethodObject) Parse() (ok *RequestLine, err Response) { +func (parser *parseMethodObject) ReadingRequestLine() (ok *requestMessage, badRequest Response) { requestLine, err := parser.readCRLFLine() if err != nil { return nil, err } - return parser.doParseRequestLine(requestLine) + return parser.parsingRequestLine(requestLine) } -func (parser *parseMethodObject) doParseRequestLine(requestLine string) (ok *RequestLine, err Response) { - requested, err := parser.parseRequestLine(requestLine) - if err != nil { - return nil, err +func (parser *parseMethodObject) parsingRequestLine(requestLine string) (ok *requestMessage, badRequest Response) { + const numFieldsInRequestLine = 3 + fields := strings.Split(requestLine, " ") + if len(fields) != numFieldsInRequestLine { + return nil, &clienterror.BadRequest{DisplayText: "incorrectly formatted or missing request-line"} } - return parser.doParseHeaders(requested) + return parser.parsingTarget(fields[0], fields[1]) } -func (parser *parseMethodObject) doParseHeaders(requested *RequestLine) (ok *RequestLine, err Response) { - err = parser.parseHeaders() - if err != nil { - return nil, err +func (parser *parseMethodObject) parsingTarget(method, target string) (ok *requestMessage, badRequest Response) { + path, query, _ := splitTarget(target) + requested := &requestMessage{ + method: method, + target: target, + path: path, } - return requested, nil + return parser.parsingQueryString(requested, query) } -func (parser *parseMethodObject) parseRequestLine(text string) (ok *RequestLine, badRequest Response) { - fields := strings.Split(text, " ") - if len(fields) != 3 { - return nil, &clienterror.BadRequest{DisplayText: "incorrectly formatted or missing request-line"} +func (parser *parseMethodObject) parsingQueryString(requested *requestMessage, rawQuery string) (ok *requestMessage, badRequest Response) { + if len(rawQuery) == 0 { + return parser.readingHeaders(requested) + } + + stringParameters := strings.Split(rawQuery, "&") + for _, stringParameter := range stringParameters { + nameValueFields := strings.Split(stringParameter, "=") + if len(nameValueFields) == 1 { + requested.AddQueryFlag(nameValueFields[0]) + } else { + decodedValue, _ := PercentDecode(nameValueFields[1]) + requested.AddQueryParameter(nameValueFields[0], decodedValue) + } } - return &RequestLine{ - Method: fields[0], - Target: fields[1], - }, nil + return parser.readingHeaders(requested) } -func (parser *parseMethodObject) parseHeaders() (badRequest Response) { +func (parser *parseMethodObject) readingHeaders(requested *requestMessage) (ok *requestMessage, badRequest Response) { isBlankLineBetweenHeadersAndBody := func(line string) bool { return line == "" } for { line, err := parser.readCRLFLine() if err != nil { - return err + return nil, err } else if isBlankLineBetweenHeadersAndBody(line) { - return nil + return requested, nil } } } @@ -87,3 +98,25 @@ func (parser *parseMethodObject) readCRLFLine() (line string, badRequest Respons trimmed := strings.TrimSuffix(maybeEndsInCR, "\r") return trimmed, nil } + +func splitTarget(target string) (path, query, fragment string) { + splitOnQuery := strings.Split(target, "?") + if len(splitOnQuery) == 1 { + query = "" + path, fragment = extractFragment(splitOnQuery[0]) + return + } + + path = splitOnQuery[0] + query, fragment = extractFragment(splitOnQuery[1]) + return +} + +func extractFragment(target string) (prefix string, fragment string) { + fields := strings.Split(target, "#") + if len(fields) == 1 { + return fields[0], "" + } else { + return fields[0], fields[1] + } +} diff --git a/http/parser_test.go b/http/parser_test.go index 0be030f..8771956 100644 --- a/http/parser_test.go +++ b/http/parser_test.go @@ -13,7 +13,7 @@ var _ = Describe("LineRequestParser", func() { Describe("#Parse", func() { var ( parser http.RequestParser - request *http.RequestLine + request http.RequestMessage err http.Response ) @@ -24,7 +24,7 @@ var _ = Describe("LineRequestParser", func() { Describe("it returns 400 Bad Request", func() { It("for a completely blank request", func() { request, err = parser.Parse(makeReader("")) - Expect(err).To(beABadRequestResponse("line in request header not ending in CRLF")) + Expect(err).To(beABadRequestResponse("end of input before terminating CRLF")) }) It("for any line missing CR", func() { @@ -44,7 +44,7 @@ var _ = Describe("LineRequestParser", func() { It("for a request missing an ending CRLF", func() { request, err = parser.Parse(makeReader("GET / HTTP/1.1\r\n")) - Expect(err).To(beABadRequestResponse("line in request header not ending in CRLF")) + Expect(err).To(beABadRequestResponse("end of input before terminating CRLF")) }) It("when multiple spaces are separating fields in request-line", func() { @@ -64,9 +64,7 @@ var _ = Describe("LineRequestParser", func() { }) Context("given a well-formed request", func() { - var ( - reader *bufio.Reader - ) + var reader *bufio.Reader BeforeEach(func() { buffer := bytes.NewBufferString("GET /foo HTTP/1.1\r\nAccept: */*\r\n\r\n") @@ -74,6 +72,11 @@ var _ = Describe("LineRequestParser", func() { request, err = parser.Parse(reader) }) + It("parses the request-line", func() { + Expect(request.Method()).To(Equal(http.GET)) + Expect(request.Target()).To(Equal("/foo")) + }) + It("returns no error", func() { Expect(err).To(BeNil()) }) @@ -83,14 +86,91 @@ var _ = Describe("LineRequestParser", func() { }) }) - Context("given a well-formed request with query parameters", func() { + Context("given a target with no query or fragment", func() { BeforeEach(func() { - request, err = parser.Parse(makeReader("GET /foo?one=1 HTTP/1.1\r\n\r\n")) - Expect(err).NotTo(HaveOccurred()) + request, _ = parser.Parse(requestWithTarget("/widget")) + }) + + It("the path is the full target", func() { + Expect(request.Path()).To(Equal("/widget")) + }) + It("there are no query parameters", func() { + Expect(request.QueryParameters()).To(BeEmpty()) + }) + }) + + Context("given a target with a query", func() { + It("the path is the part before the ?", func() { + request, _ = parser.Parse(requestWithTarget("/widget?field=value")) + Expect(request.Path()).To(Equal("/widget")) + }) + + It("parses the part after the ? into query parameters", func() { + request, _ = parser.Parse(requestWithTarget("/widget?field=value")) + Expect(request.QueryParameters()).To( + ContainElement(http.QueryParameter{Name: "field", Value: "value"})) + }) + + It("parses parameters without a value into QueryParameter#Name", func() { + request, _ = parser.Parse(requestWithTarget("/widget?flag")) + Expect(request.QueryParameters()).To( + ContainElement(http.QueryParameter{Name: "flag", Value: ""})) }) - XIt("removes the query string from the target") - XIt("passes decoded parameters to the routes") + It("uses '=' to split a parameter's name and value", func() { + request, _ = parser.Parse(requestWithTarget("/widget?field=value")) + Expect(request.QueryParameters()).To( + ContainElement(http.QueryParameter{Name: "field", Value: "value"})) + }) + + It("uses '&' to split among multiple parameters", func() { + request, _ = parser.Parse(requestWithTarget("/widget?one=1&two=2")) + Expect(request.QueryParameters()).To(Equal([]http.QueryParameter{ + {Name: "one", Value: "1"}, + {Name: "two", Value: "2"}, + })) + }) + }) + + Context("given a target with a fragment", func() { + BeforeEach(func() { + request, _ = parser.Parse(requestWithTarget("/widget#section")) + }) + + It("the path is the part before the '#'", func() { + Expect(request.Path()).To(Equal("/widget")) + }) + It("there are no query parameters", func() { + Expect(request.QueryParameters()).To(BeEmpty()) + }) + }) + + Context("given a target with a query and a fragment", func() { + BeforeEach(func() { + request, _ = parser.Parse(requestWithTarget("/widget?field=value#section")) + }) + + It("the path is the part before the ?", func() { + Expect(request.Path()).To(Equal("/widget")) + }) + It("query parameters are parsed from the part between the ? and the #", func() { + Expect(request.QueryParameters()).To(Equal([]http.QueryParameter{ + {Name: "field", Value: "value"}, + })) + }) + }) + + Context("given a target with percent-encoded query parameters", func() { + It("decodes percent-encoded values", func() { + request, _ = parser.Parse(requestWithTarget("/widget?less=%3C")) + Expect(request.QueryParameters()).To(Equal([]http.QueryParameter{ + {Name: "less", Value: "<"}, + })) + }) }) }) }) + +func requestWithTarget(target string) *bufio.Reader { + return makeReader("GET %s HTTP/1.1\r\n\r\n", target) +} diff --git a/http/percent.go b/http/percent.go new file mode 100644 index 0000000..77d2ec4 --- /dev/null +++ b/http/percent.go @@ -0,0 +1,46 @@ +package http + +import ( + "bytes" + "fmt" + "strconv" + "strings" +) + +func PercentDecode(field string) (decoded string, malformed error) { + outputBuffer := &bytes.Buffer{} + splits := strings.Split(field, "%") + unencodedPrefix, hexCodePrefixedSubstrings := splits[0], splits[1:] + + outputBuffer.WriteString(unencodedPrefix) + for _, hexCodePlusUnencoded := range hexCodePrefixedSubstrings { + if len(hexCodePlusUnencoded) < 2 { + return "", UnfinishedPercentEncoding{EnclosingField: field} + } + + hexCodeCharacters, unencodedRemainder := splitAfterHexCode(hexCodePlusUnencoded) + outputBuffer.WriteByte(decode(hexCodeCharacters)) + outputBuffer.WriteString(unencodedRemainder) + } + + return outputBuffer.String(), nil +} + +func splitAfterHexCode(hexCodePlusUnencoded string) (hexCode string, unencoded string) { + return hexCodePlusUnencoded[:2], hexCodePlusUnencoded[2:] +} + +func decode(octetCharacters string) byte { + const base16 = 16 + const uintSizeInBits = 8 + asciiCode, _ := strconv.ParseInt(octetCharacters, base16, uintSizeInBits) + return byte(asciiCode) +} + +type UnfinishedPercentEncoding struct { + EnclosingField string +} + +func (invalid UnfinishedPercentEncoding) Error() string { + return fmt.Sprintf("%% followed by fewer than 2 characters: %s", invalid.EnclosingField) +} diff --git a/http/percent_test.go b/http/percent_test.go new file mode 100644 index 0000000..90dc081 --- /dev/null +++ b/http/percent_test.go @@ -0,0 +1,42 @@ +package http_test + +import ( + "github.com/kkrull/gohttp/http" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("PercentDecode", func() { + It("returns a string unchanged that has no percent triplets in it", func() { + Expect(http.PercentDecode("abcd")).To(Equal("abcd")) + }) + + It("decodes a % triplet into the ASCII character for its hexadecimal code", func() { + Expect(http.PercentDecode("%3C")).To(Equal("<")) + }) + + It("retains characters after a percent triplet", func() { + Expect(http.PercentDecode("%3Cabc")).To(Equal("