Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions cmd/web/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,22 @@ import (
"strconv"

"snippetbox.tonidefez.net/internal/models"
"snippetbox.tonidefez.net/internal/validator"
)

// Define a snippetCreateForm struct to represent the form data and validation
// errors for the form fields. Note that all the struct fields are deliberately
// exported (i.e. start with a capital letter). This is because struct fields
// must be exported in order to be read by the html/template package when
// rendering the template.
type snippetCreateForm struct {
Title string
Content string
Expires int
FieldErrors map[string]string
validator.Validator
}

func (app *Application) Home(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "Go")

Expand Down Expand Up @@ -50,15 +64,60 @@ func (app *Application) SnippetView(w http.ResponseWriter, r *http.Request) {
}

func (app *Application) SnippetCreate(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Display a form for creating a new snippet..."))
data := app.NewTemplateData(r)

data.Form = snippetCreateForm{
Expires: 365,
}

app.render(w, r, http.StatusOK, "create.tmpl", *data)
}

func (app *Application) SnippetCreatePost(w http.ResponseWriter, r *http.Request) {
title := "O snail"
content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa"
expires := 7
err := r.ParseForm()
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}

// Get the expires value from the form as normal.
expires, err := strconv.Atoi(r.PostForm.Get("expires"))
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}

// Create an instance of the snippetCreateForm struct containing the values
// from the form and an empty map for any validation errors.
form := snippetCreateForm{
Title: r.PostForm.Get("title"),
Content: r.PostForm.Get("content"),
Expires: expires,
}

// Because the Validator struct is embedded by the snippetCreateForm struct,
// we can call CheckField() directly on it to execute our validation checks.
// CheckField() will add the provided key and error message to the
// FieldErrors map if the check does not evaluate to true. For example, in
// the first line here we "check that the form.Title field is not blank". In
// the second, we "check that the form.Title field has a maximum character
// length of 100" and so on.
form.CheckField(validator.NotBlank(form.Title), "title", "This field cannot be blank")
form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long")
form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7 or 365")

// Use the Valid() method to see if any of the checks failed. If they did,
// then re-render the template passing in the form in the same way as
// before.
if !form.Valid() {
data := app.NewTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "create.tmpl", *data)
return
}

id, err := app.snippets.Insert(title, content, expires)
id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
if err != nil {
app.serverError(w, r, err)
return
Expand Down
41 changes: 39 additions & 2 deletions cmd/web/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

Expand Down Expand Up @@ -113,10 +114,16 @@ func TestSnippetCreateGet(t *testing.T) {
// simulate dependencies
dummyLogger := slog.New(slog.NewTextHandler(io.Discard, nil))
dummyDB := &models.SnippetModel{DB: nil}
templateCache, err := newTemplateCache()

if err != nil {
t.Fatal(err)
}

app := &Application{
logger: dummyLogger,
snippets: dummyDB,
logger: dummyLogger,
snippets: dummyDB,
templateCache: templateCache,
}

router := app.routes()
Expand Down Expand Up @@ -158,3 +165,33 @@ func TestSnippetCreatePost(t *testing.T) {
t.Errorf("expected Location header %q; got %q", expectedLocation, actualLocation)
}
}

func TestSnippetCreatePost_InvalidData(t *testing.T) {
templateCache, err := newTemplateCache()

if err != nil {
t.Fatal(err)
}
app := &Application{
templateCache: templateCache,
}

form := url.Values{}
form.Add("title", "")
form.Add("content", "Some valid content")
form.Add("expires", "7")

req := httptest.NewRequest(http.MethodPost, "/snippet/create", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

rr := httptest.NewRecorder()

app.SnippetCreatePost(rr, req)

res := rr.Result()
defer res.Body.Close()

if res.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("expected status 422 OK; got %d", res.StatusCode)
}
}
9 changes: 9 additions & 0 deletions cmd/web/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"runtime/debug"

"snippetbox.tonidefez.net/internal/models"
)

// The serverError helper writes a log entry at Error level (including the request
Expand Down Expand Up @@ -49,3 +51,10 @@ func (app *Application) render(w http.ResponseWriter, r *http.Request, status in
w.WriteHeader(status)
buf.WriteTo(w)
}

func (app *Application) NewTemplateData(r *http.Request) *templateData {
return &templateData{
Snippet: models.Snippet{},
Snippets: []models.Snippet{},
}
}
6 changes: 4 additions & 2 deletions cmd/web/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
// At the moment it only contains one field, but we'll add more
// to it as the build progresses.
type templateData struct {
Snippet models.Snippet
Snippets []models.Snippet
CurrentYear int
Snippet models.Snippet
Snippets []models.Snippet
Form any
}

func newTemplateCache() (map[string]*template.Template, error) {
Expand Down
56 changes: 56 additions & 0 deletions internal/validator/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package validator

import (
"slices"
"strings"
"unicode/utf8"
)

// Define a new Validator struct which contains a map of validation error messages
// for our form fields.
type Validator struct {
FieldErrors map[string]string
}

// Valid() returns true if the FieldErrors map doesn't contain any entries.
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0
}

// AddFieldError() adds an error message to the FieldErrors map (so long as no
// entry already exists for the given key).
func (v *Validator) AddFieldError(key, message string) {
// Note: We need to initialize the map first, if it isn't already
// initialized.
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}

if _, exists := v.FieldErrors[key]; !exists {
v.FieldErrors[key] = message
}
}

// CheckField() adds an error message to the FieldErrors map only if a
// validation check is not 'ok'.
func (v *Validator) CheckField(ok bool, key, message string) {
if !ok {
v.AddFieldError(key, message)
}
}

// NotBlank() returns true if a value is not an empty string.
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}

// MaxChars() returns true if a value contains no more than n characters.
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(value) <= n
}

// PermittedValue() returns true if a value is in a list of specific permitted
// values.
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
return slices.Contains(permittedValues, value)
}
43 changes: 43 additions & 0 deletions ui/html/pages/create.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{{define "title"}}Create a New Snippet{{end}}

{{define "main"}}
<form action='/snippet/create' method='POST'>
<div>
<label>Title:</label>
<!-- Use the `with` action to render the value of .Form.FieldErrors.title
if it is not empty. -->
{{with .Form.FieldErrors.title}}
<label class='error'>{{.}}</label>
{{end}}
<!-- Re-populate the title data by setting the `value` attribute. -->
<input type='text' name='title' value='{{.Form.Title}}'>
</div>
<div>
<label>Content:</label>
<!-- Likewise render the value of .Form.FieldErrors.content if it is not
empty. -->
{{with .Form.FieldErrors.content}}
<label class='error'>{{.}}</label>
{{end}}
<!-- Re-populate the content data as the inner HTML of the textarea. -->
<textarea name='content'>{{.Form.Content}}</textarea>
</div>
<div>
<label>Delete in:</label>
<!-- And render the value of .Form.FieldErrors.expires if it is not empty. -->
{{with .Form.FieldErrors.expires}}
<label class='error'>{{.}}</label>
{{end}}
<!-- Here we use the `if` action to check if the value of the re-populated
expires field equals 365. If it does, then we render the `checked`
attribute so that the radio input is re-selected. -->
<input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
<!-- And we do the same for the other possible values too... -->
<input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
<input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
</div>
<div>
<input type='submit' value='Publish snippet'>
</div>
</form>
{{end}}
2 changes: 2 additions & 0 deletions ui/html/partials/nav.tmpl
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{{define "nav"}}
<nav>
<a href='/'>Home</a>
<!-- Add a link to the new form -->
<a href='/snippet/create'>Create snippet</a>
</nav>
{{end}}
Loading