-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.go
544 lines (474 loc) · 19.6 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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/stivesso/articles-search/pkg/db"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"reflect"
"slices"
"strconv"
"strings"
)
// Article represents the structure of an Article.
type Article struct {
Id string `json:"id" validate:"required,validUuid"` // Id represents the unique identifier of an Article, it is a JSON field that is required and must be a valid UUID.
Title string `json:"title" validate:"required"` // Title represents the title of an article which is a required field that must be populated.
Content string `json:"content" validate:"omitempty"` // Content represents the content of an Article, it is a JSON field that can be empty.
Author string `json:"author" validate:"omitempty"` // Author represents the author of an Article.
Tags []string `json:"tags" validate:"omitempty"` // Tags represents the tags associated with an Article. It is a JSON field that can be empty.
}
// CustomOutput for standardized error and message responses.
type CustomOutput struct {
Error string `json:"Error,omitempty"`
Message string `json:"Message,omitempty"`
}
var (
databaseClient db.DbClient
ctx = context.Background()
validate = validator.New()
searchIndexName = "idx_articles"
keysPrefix = "article:"
)
func main() {
// Register validate for tag validUuid
err := validate.RegisterValidation("validUuid", uuidValidation)
if err != nil {
log.Fatalf("Unable to register the function required to validate article data, error was: %v", err)
}
// Initialize Database client.
err = initializeDatabase()
if err != nil {
log.Fatalf("Failed to connect to Database: %v", err)
}
// Setup HTTP server and routes.
setupHTTPServer()
}
/*
Helper functions
*/
// initializeDatabase initializes the database by checking the required environment variables AS_DBSERVER and AS_DBPORT.
func initializeDatabase() error {
var err error
dbServer := os.Getenv("AS_DBSERVER")
dbPort := os.Getenv("AS_DBPORT")
if dbServer == "" || dbPort == "" {
return errors.New("The following environment variables need to be set: \n AS_DBSERVER for the Database Server\n AS_DBPORT for the Database Port")
}
dbPortInt, err := strconv.Atoi(dbPort)
if err != nil {
return fmt.Errorf("unable to convert environment variable AS_DBPORT to a valid integer, the exact error was: %v", err)
}
databaseClient, err = db.NewDbClient(dbServer, dbPortInt, "", 0)
return err
}
// setupHTTPServer sets up and starts an HTTP server on address ":8080".
// It configures route handlers for various endpoints and starts the server.
// Use mux.HandleFunc to define route handlers for each endpoint.
func setupHTTPServer() {
mux := http.NewServeMux()
// Define routes using pattern matching for IDs.
mux.HandleFunc("GET /articles", getAllArticles)
mux.HandleFunc("GET /article/{id}", getArticleByID)
mux.HandleFunc("POST /articles", createArticle)
mux.HandleFunc("PUT /article/{id}", updateArticleByID)
mux.HandleFunc("DELETE /article/{id}", deleteArticleByID)
mux.HandleFunc("GET /articles/search", searchArticles)
serverAddress := ":8080" // HardCoded for this test
slog.Info(fmt.Sprintf("Starting HTTP Server on address %s\n", serverAddress))
if err := http.ListenAndServe(serverAddress, mux); err != nil {
log.Fatalf("Failed to start HTTP server: %v", err)
}
}
// responseJSON simplifies JSON response writing.
func responseJSON(w http.ResponseWriter, v interface{}, statusCode int) {
jsonResp, err := json.MarshalIndent(v, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(statusCode)
w.Header().Set("Content-Type", "application/json")
nbrBytesWritten, err := w.Write(jsonResp)
if err != nil {
slog.Error("Unable to write the following response", "response", jsonResp, "lenght_response", nbrBytesWritten)
}
}
// handleError simplifies error handling and response.
func handleError(w http.ResponseWriter, errMsg string, err error, statusCode int) {
//Logging any 5xx error
if statusCode >= http.StatusInternalServerError {
slog.Error(errMsg, "Error:", err)
}
responseJSON(w, CustomOutput{Error: err.Error(), Message: errMsg}, statusCode)
}
// isQueryParamsExpected checks if a list of query parameters are expected
func isQueryParamsExpected(queryParams url.Values, expectedParams []string) error {
for param := range queryParams {
if !slices.Contains(expectedParams, param) {
return fmt.Errorf("%s query provided is not one of the following parameter: %v", param, expectedParams)
}
}
return nil
}
// uuidValidation validates if a given field is a valid UUID format using the UUID.Parse() function.
// It returns a boolean value indicating whether the validation succeeds or fails.
func uuidValidation(fl validator.FieldLevel) bool {
_, err := uuid.Parse(fl.Field().String())
return err == nil
}
// structFieldsJsonTags returns a list containing fields JSON tags of a struct
// If the provided parameter is not a struct, then the returned Slice will be nil
func structFieldsJsonTags(givenStruct any) []string {
t := reflect.TypeOf(givenStruct)
var listOfTags []string
if t.Kind() == reflect.Struct {
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("json")
listOfTags = append(listOfTags, tag)
}
}
return listOfTags
}
// buildSearchParams builds a list of db.SearchParams
// by matching json tags on the given Struct with the parameters provided
func buildSearchParams(providedParams url.Values, givenStruct any) []db.SearchParams {
var searchParameters []db.SearchParams
givenStructType := reflect.TypeOf(givenStruct)
if givenStructType.Kind() == reflect.Struct {
for param, fieldToSearch := range providedParams {
// Check if the param is one of the JSON tags in the given struct
var field reflect.StructField
var found bool
for i := 0; i < givenStructType.NumField(); i++ {
if givenStructType.Field(i).Tag.Get("json") == param {
field = givenStructType.Field(i)
found = true
break
}
}
if !found {
continue // Skip if the parameter doesn't correspond to a field in the Given struct
}
var newSearchParam db.SearchParams
newSearchParam.Param = strings.ToLower(param)
newSearchParam.Value = fieldToSearch
// Determine the type of the field
switch field.Type.Kind() {
case reflect.Slice:
newSearchParam.Type = db.ArrayType
case reflect.String:
newSearchParam.Type = db.StringType
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
newSearchParam.Type = db.NumberType
case reflect.Bool:
newSearchParam.Type = db.BooleanType
case reflect.Map:
newSearchParam.Type = db.ObjectType
// Will Add more cases as needed for other types
// For now, only db.ArrayType really matter as that correlate with tags
default:
newSearchParam.Type = db.StringType
}
searchParameters = append(searchParameters, newSearchParam)
}
}
return searchParameters
}
/*
Handlers Functions
*/
// getAllArticles retrieves all articles from the database and returns them as a JSON response.
// It uses db.GetAllKeys to get a list of article keys and db.JSONMGet to retrieve the article details for each key.
// The function then validates and appends the first article element to the result. Finally, it sends the result as a JSON response.
func getAllArticles(w http.ResponseWriter, r *http.Request) {
var articles []Article
// Use Scan to efficiently iterate through keys with the specified keysPrefix.
keys, err := db.GetAllKeys(ctx, databaseClient, keysPrefix)
if err != nil {
handleError(w, "Failed to retrieve article keys from Database", err, http.StatusInternalServerError)
return
}
if len(keys) == 0 {
// No articles found, return an empty list with HTTP 200 OK.
responseJSON(w, []Article{}, http.StatusOK)
return
}
// Retrieve article details for each key
resultMget, err := db.JSONMGet(ctx, databaseClient, keys)
if err != nil {
handleError(w, "An Error Occurred while Getting Articles", err, http.StatusInternalServerError)
return
}
if resultMget == nil {
// No articles found, return an empty list with HTTP 200 OK.
responseJSON(w, articles, http.StatusOK)
return
}
// Loop on each element in the array and append its first element to the result after validation
var result []Article
for _, responseRetrievedArticle := range resultMget {
var resultForThisArticle []Article
responseArticle, isString := responseRetrievedArticle.(string)
if !isString {
handleError(w, "An Error Occurred while Getting Articles", fmt.Errorf("article returned in incorrect format"), http.StatusInternalServerError)
return
}
err = json.Unmarshal([]byte(responseArticle), &resultForThisArticle)
if err != nil {
handleError(w, "Unable to validate the structure of returned Article", err, http.StatusInternalServerError)
return
}
result = append(result, resultForThisArticle[0])
}
responseJSON(w, result, http.StatusOK)
}
// getArticleByID retrieves an article from the database using the provided ID.
// It builds a database key using the article ID and then uses db.JSONGet to retrieve the article.
// If the article is not found, it returns an HTTP 404 Not Found response.
// The function then unmarshals the article JSON into an Article struct and returns it as a JSON response.
// If any unexpected errors occur during the process, it uses handleError to handle the errors and respond with an appropriate HTTP status code and message.
func getArticleByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Build the Database key using the article ID.
key := fmt.Sprintf("%s%s", keysPrefix, id)
// Retrieve the article from Database.
result, err := db.JSONGet(ctx, databaseClient, key)
if err != nil {
// Handle unexpected Database errors.
handleError(w, "Failed to retrieve article from Database", err, http.StatusInternalServerError)
return
}
if result == "" {
// Article not found, respond with HTTP 404 Not Found.
handleError(w, fmt.Sprintf("No article found with ID %s", id), nil, http.StatusNotFound)
return
}
// Unmarshal the article JSON into the Article struct.
var article Article
if err := json.Unmarshal([]byte(result), &article); err != nil {
handleError(w, "Failed to parse article data", err, http.StatusInternalServerError)
return
}
// Return the article as JSON.
responseJSON(w, article, http.StatusOK)
}
// createArticle handles the creation of articles. It reads the request body and expects
// either an array of Article objects or a single Article object. The function performs
// validation on each article, generates a unique ID if one is not provided, checks if the article
// already exists in the database, and sets the articles in the database using JSONMSet.
// The response is sent as JSON.
//
// If the provided JSON is not a list of articles or an article, it returns an error with a
// Bad Request status code.
//
// If the JSON decoding, validation, reading, unmarshaling, or setting of articles in the
// database fails, it returns an error with the appropriate status code.
func createArticle(w http.ResponseWriter, r *http.Request) {
var articlesSetArgs []db.JSONSetArgs
var articles []*Article
jsonDecoder := json.NewDecoder(r.Body)
// read the first token that will help check if it's an array or a single object
typeChecker, err := jsonDecoder.Token()
if err != nil {
handleError(w, "Error reading JSON", err, http.StatusBadRequest)
return
}
switch typeChecker {
case json.Delim('['): // The token is an opening bracket, indicating an array
// Decode each element and store in articles
for jsonDecoder.More() {
var article Article
// decode an array value
err := jsonDecoder.Decode(&article)
if err != nil {
handleError(w, "Failed to decode request body", err, http.StatusBadRequest)
return
}
articles = append(articles, &article)
}
case json.Delim('{'): // The token is an opening brace, indicating a single object
// Create a buffer and write the opening brace to it, since it was already consumed
var buf bytes.Buffer
buf.WriteByte('{')
// Read the remainder of the JSON object from the decoder's buffer
_, err := buf.ReadFrom(jsonDecoder.Buffered())
if err != nil && err != io.EOF {
handleError(w, "Failed to read request body", err, http.StatusBadRequest)
return
}
// Unmarshal the JSON bytes from the buffer into an article
var article Article
if err := json.Unmarshal(buf.Bytes(), &article); err != nil {
handleError(w, "Failed to unmarshal JSON", err, http.StatusBadRequest)
return
}
articles = append(articles, &article)
default:
handleError(w, "Invalid JSON format", errors.New("the Provided JSON is neither a list of articles nor an article"), http.StatusBadRequest)
}
// Validate and Database Set arguments needed for Database JSONMSet
for _, article := range articles {
if article.Id == "" {
// Generate a unique UUID
newId := uuid.New()
article.Id = newId.String()
}
if validateErr := validate.Struct(article); validateErr != nil {
handleError(w, fmt.Sprintf("Validation failed for article %+v", article), validateErr, http.StatusBadRequest)
return
}
key := fmt.Sprintf("%s%s", keysPrefix, article.Id)
// Check if the article already exists in Database
exists, err := db.Exists(ctx, databaseClient, key)
if err != nil {
handleError(w, "Error checking if article exists", err, http.StatusInternalServerError)
return
}
if exists != 0 {
handleError(w, fmt.Sprintf("article with ID %s found in Database", article.Id), fmt.Errorf("duplicate Article Id"), http.StatusNotFound)
return
}
// Note: For now JSONSetArgs does not seem to marshaled back JSON
// Hence, we marshall this before setting as Argument
articleByte, errMarshall := json.Marshal(article)
if errMarshall != nil {
handleError(w, fmt.Sprintf("Creating article with ID %s in the Database failed. No Article Added", article.Id), errMarshall, http.StatusInternalServerError)
return
}
articlesSetArgs = append(articlesSetArgs, db.JSONSetArgs{
Key: key,
Path: "$",
Value: articleByte,
})
}
// Set the result in Database, using JSONMSet
result, err := db.JSONMSetArgs(ctx, databaseClient, articlesSetArgs)
if err != nil {
handleError(w, "creating articles in the Database failed", err, http.StatusInternalServerError)
return
}
// With error from JSONMSetArgs being nil, we should not expect result to not be OK
if result != "OK" {
handleError(w, "unexpected failure while creating articles in the Database", errors.New("JSONMSetArgs returns not ok result"), http.StatusInternalServerError)
}
// Output only the ID of the articles
outputArticles := make([]struct {
Id string `json:"id"`
}, len(articles))
for i := range articles {
outputArticles[i].Id = articles[i].Id
}
responseJSON(w, outputArticles, http.StatusOK)
}
// updateArticleByID updates an article with the provided ID in the database.
// It decodes the JSON payload from the request body and populates the article struct.
// Then, it validates the article struct using the validate library.
// Next, it checks if the article exists in the database.
// If the article does not exist, it responds with an HTTP 404 Not Found error.
// Otherwise, it updates the article in the database using the key built from the ID.
// Finally, it responds with the updated article as a JSON response.
func updateArticleByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Decode the JSON payload directly from the request body
var article Article
if err := json.NewDecoder(r.Body).Decode(&article); err != nil {
handleError(w, "Invalid JSON payload", err, http.StatusBadRequest)
return
}
article.Id = id
// Validate the article struct
if err := validate.Struct(article); err != nil {
handleError(w, "Validation failed for article", err, http.StatusBadRequest)
return
}
// Check if the article exists in Database
key := fmt.Sprintf("%s%s", keysPrefix, id)
exists, err := db.Exists(ctx, databaseClient, key)
if err != nil {
handleError(w, "Error checking if article exists", err, http.StatusInternalServerError)
return
}
if exists == 0 {
handleError(w, "Article not found", fmt.Errorf("no article found with ID %s", id), http.StatusNotFound)
return
}
// Update the article in Database
if _, err = db.JSONSet(ctx, databaseClient, key, "$", article); err != nil {
handleError(w, "Failed to update article in Database", err, http.StatusInternalServerError)
return
}
// Respond with the updated article
responseJSON(w, article, http.StatusOK)
}
// deleteArticleByID deletes an article from the database using the provided ID.
// It constructs the database key for the article by concatenating the keysPrefix and the provided ID.
// It then checks if the article exists in the database before attempting to delete it.
// If the article does not exist, it returns an HTTP 404 Not Found response.
// If there is an error while checking if the article exists, it uses handleError to handle the error and respond with an appropriate HTTP status code and message.
// If there is an error while deleting the article, it uses handleError to handle the error and respond with an appropriate HTTP status code and message.
// Finally, it responds with a success message indicating that the article has been successfully deleted.
func deleteArticleByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Construct the Database key for the article
key := fmt.Sprintf("%s%s", keysPrefix, id)
// Check if the article exists before attempting to delete
exists, err := db.Exists(ctx, databaseClient, key)
if err != nil {
handleError(w, "Error checking if article exists", err, http.StatusInternalServerError)
return
}
if exists == 0 {
handleError(w, "Article not found", fmt.Errorf("no article found with ID %s", id), http.StatusNotFound)
return
}
// Delete the article from Database
if _, err := db.Del(ctx, databaseClient, key); err != nil {
handleError(w, "Failed to delete article from Database", err, http.StatusInternalServerError)
return
}
// Respond to indicate successful deletion
responseJSON(w, CustomOutput{Message: fmt.Sprintf("article with ID %s successfully deleted", id)}, http.StatusOK)
}
// searchArticles handles the search functionality for articles based on the provided query parameters.
// It validates the parameters, builds the search parameters, and runs the search query.
// The search results are returned in the HTTP response.
func searchArticles(w http.ResponseWriter, r *http.Request) {
// Getting Expected parameters from Article JSON Tags
expectedParams := structFieldsJsonTags(Article{})
providedParams := r.URL.Query()
invalidSearchError := "invalid search parameter"
if len(providedParams) == 0 {
handleError(w,
invalidSearchError,
fmt.Errorf("you must provide at least one of the following parameter: %v", expectedParams), http.StatusBadRequest,
)
return
}
// Check that the provided parameters are in expected Parameters
if err := isQueryParamsExpected(providedParams, expectedParams); err != nil {
handleError(w, invalidSearchError, err, http.StatusBadRequest)
return
}
// Database Search Parameter
searchParameters := buildSearchParams(providedParams, Article{})
// Run the Search Query
resArticles, err := db.Search[Article](ctx, databaseClient, searchIndexName, searchParameters)
if err != nil {
genericDbErrorMsg := fmt.Sprintf("Database Error while searching with parameter: %s", providedParams.Encode())
handleError(w, genericDbErrorMsg, err, http.StatusInternalServerError)
return
}
responseJSON(w, resArticles, http.StatusOK)
}