forked from ProlificLabs/shakesearch_old
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.go
232 lines (200 loc) · 5.76 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
/*
Package main implements the search functionality as well as
receiving and responding to HTTP requests. It tries to adhere to the
contract in the /api/api-spec.yml OpenAPI spec
....
*/
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
)
func main() {
searcher := Searcher{}
err := searcher.Load("completeworks.txt")
if err != nil {
log.Fatal(err)
}
// TODO cache these static assets
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
http.HandleFunc("/search", handleSearch(searcher))
port := os.Getenv("PORT")
if port == "" {
port = "3001"
}
fmt.Printf("Listening on port %s...\n", port)
err = http.ListenAndServe(fmt.Sprintf(":%s", port), nil)
if err != nil {
log.Fatal(err)
}
}
/* A Query encapsulates all the valid parameters in the HTTP request */
type Query struct {
searchTerm string
limit int32
page int32
orderby string
sortby string
}
/* A Match represents an entry for every match for the search term */
type Match struct {
Phrase string `json:"phrase"`
}
/* A Result encapsulates the HTTP response payload */
type Result struct {
Total int32 `json:"total"`
Page int32 `json:"page"`
Data []Match `json:"data"`
Duration int64 `json:"duration"`
}
/* A data structure for the data to be searched */
type Searcher struct {
data string
}
/* Enable CORS so that this backend can respond to calls
* from other domains. E.g from API integrators
*/
func enableCors(w *http.ResponseWriter) {
(*w).Header().Set("Access-Control-Allow-Origin", "*")
}
/*
* Scrutinse HTTP request and raise BadRequest error where necessary. Extract
* the request data if all goes well
*/
func parseRequest(w http.ResponseWriter, r *http.Request, regx *regexp.Regexp) Query {
params := r.URL.Query()
searchQry, ok := params["q"]
if !ok || len(searchQry) < 1 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request. Missing search query in URL params"))
}
// TODO match regex pattern
term := strings.Trim(searchQry[0], " ")
if !regx.MatchString(term) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request. Please enter a valid search query"))
}
limit := 25
limitQry := params["limit"]
if len(limitQry) >= 1 {
lmt, ok := strconv.Atoi(strings.Trim(limitQry[0], " "))
if ok != nil || lmt < 1 || lmt > 500 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request. The limit param needs to be an int >= 1 and <= 500"))
}
limit = lmt
}
page := 1
pageQry := params["page"]
if len(pageQry) >= 1 {
pg, ok := strconv.Atoi(strings.Trim(pageQry[0], " "))
if ok != nil || pg < 1 || pg > 100 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request. The page param needs to be an int >= 1 and <= 100"))
}
page = pg
}
orderBy := "occurence"
orderByQry := params["orderby"]
if len(orderByQry) >= 1 {
odr := strings.ToLower(strings.Trim(orderByQry[0], " "))
if odr != "occurence" && odr != "frequency" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request. You can only order matches by frequency or occurence"))
}
orderBy = odr
}
sortBy := "DESC"
sortByQry := params["sortby"]
if len(sortByQry) >= 1 {
srt := strings.ToUpper(strings.Trim(sortByQry[0], " "))
if srt != "ASC" && srt != "DESC" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request. You can only sort matches in ascending(ASC) or descending(DESC) order"))
}
sortBy = srt
}
qParams := Query{
sortby: sortBy,
orderby: orderBy,
searchTerm: term,
limit: int32(limit),
page: int32(page),
}
return qParams
}
/* We've received a search request. Delegate to the right handlers to parse/validate
* the incoming data, perform the search and build the respose which will get sent to the client
*/
func handleSearch(searcher Searcher) func(w http.ResponseWriter, r *http.Request) {
regx := regexp.MustCompile(`^[a-zA-Z]{3}[ a-zA-Z]*$`)
return func(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
timeFmt := "2006.01.02 15:04:05"
query := parseRequest(w, r, regx)
fmt.Printf("%v\tHandling search for %v\n", time.Now().Format(timeFmt), query.searchTerm)
result := searcher.Search(query)
fmt.Printf("%v\tResults are in. Found %v in %v ms\n", time.Now().Format(timeFmt), result.Total, result.Duration)
jsonResp, err := json.Marshal(result)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Error encoding response ..."))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonResp)
}
}
/* Load the txt file into memory and be ready to search through
* it when a request comes in
*/
func (s *Searcher) Load(filename string) error {
data, err := ioutil.ReadFile(filename)
if err != nil {
return fmt.Errorf("Load: %w", err)
}
s.data = string(data)
return nil
}
/* We've received a request. Perform search and return portions
* of the data containing matches of the search term
*/
func (s *Searcher) Search(query Query) Result {
starTime := time.Now()
data := []Match{}
term := query.searchTerm
limit := query.limit
offset := query.page
start, end := (offset*limit)-limit, (offset * limit)
// TODO investigate https://www.nightfall.ai/blog/best-go-regex-library
reg := regexp.MustCompile(fmt.Sprintf(`(?i)%v`, term))
matches := reg.FindAllStringIndex(s.data, -1)
count := 0
if matches != nil {
count = len(matches)
for _, pos := range matches {
data = append(data, Match{
Phrase: s.data[pos[0]-100 : pos[1]+100],
})
}
data = data[start:end]
}
elapsed := time.Since(starTime)
result := Result{
Total: int32(count),
Page: offset,
Data: data,
Duration: int64(elapsed.Milliseconds()),
}
return result
}