Initial implementation

This commit is contained in:
Borna Rajković 2023-06-20 16:10:46 +02:00
parent bb4f465321
commit 2172b8232e
16 changed files with 838 additions and 57 deletions

2
.gitignore vendored
View File

@ -1,3 +1,3 @@
.idea/** .idea/**
template holiday-api
.env .env

106
README.md
View File

@ -1,22 +1,96 @@
# go-web-app-template # Holiday api
Basic template for creating go applications Simple api used for tracking holidays
Includes: ## Endpoints
* dotenv
* pq
* sqlx
* chi
* google.uuid
To start using make sure to setup either environment variables listed Fetching is done via `GET /api/v1/holidays` endpoint
below, or create .env file and copy variables below
That endpoint accepts a list of required and optional parameters
### Required parameters
`country`
- determines for which country holidays are fetched, uses ISO 3166-1 Alpha-2 codes [more info here](https://en.wikipedia.org/wiki/ISO_3166-1)
- eg. `country=US`
### Optional parameters
`year`
- returns only holidays for given year
- eg. `year=2023`
- only used if no more important parameters are defined
`date`
- returns only holidays for given date
- date must be formatted in ISO 8601 format [more info here](https://www.iso.org/iso-8601-date-and-time-format.html)
- eg. `date=2021-12-25`
- if defined year and rangeStart|rangeEnd parameters are ignored
`rangeStart|rangeEnd`
- returns holidays in given range with both ends being inclusive
- if either limit isn't defined it is assumed to be up to or all from given limit (if rangeStart isn't defined all holidays before rangeEnd are returned and vice-verse)
- dates must be formatted in ISO 8601 format [more info here](https://www.iso.org/iso-8601-date-and-time-format.html)
- eg. `rangeStart=2021-12-25&rangeEnd=2023-01-23`, `rangeStart=2023-01-20`
- if defined year parameter is ignored
`stateHoliday`
- if set true only holidays that are tagged as state holidays are returned, similar for if set false, if not set all holidays are returned
- eg. `stateHoliday=true`, `stateHoliday=false`
`religiousHoliday`
- if set true only holidays that are tagged as religious holidays are returned, similar for if set false, if not set all holidays are returned
- eg. `religiousHoliday=true`, `religiousHoliday=false`
#### Paging
`pageSize`
- returns at most pageSize number of holidays
- eg. `pageSize=20`
- only applied if page is defined as well, by default set to 20
`page`
- returns nth page of holidays, paging starts at 0
- eg. `page=0`
## Response
By default, responses are returned as a json array
``` ```
PSQL_HOST=localhost [{
PSQL_PORT=5432 id: string;
PSQL_USER=template date: string(ISO 8601);
PSQL_PASSWORD=templatePassword name: string;
PSQL_DB=template description: string;
isStateHoliday: boolean;
isReligiousHoliday: boolean;
},...]
```
eg.
```
[{
"id": "74a2a769-abf2-45d4-bdc4-442bbcc89138",
"date": "2023-12-25",
"name": "Christmas",
"description": "TBD",
"isStateHoliday": true,
"isReligiousHoliday": true
}]
``` ```
Also, database is required for template to start, so you can start it with `docker compose up -d` But can be returned as XML or CSV by setting appropriate `Accept` header (application/xml, text/xml or text/csv)
XML Response
```
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<Holidays>
<Holiday id="74a2a769-abf2-45d4-bdc4-442bbcc89138" date="2023-12-25" isReligious="true" isState="true">
<name>Christmas</name>
<description>TBD</description>
</Holiday>
</Holidays>
```
CSV Response
```
id,date,name,description,is_state_holiday,is_religious_holiday
74a2a769-abf2-45d4-bdc4-442bbcc89138,2023-12-25,Christmas,TBD,true,true
```

View File

@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS "holiday"
(
id uuid,
country char(2),
date date,
name varchar(64),
description varchar(512),
is_state boolean,
is_religious boolean
);
INSERT INTO "holiday" (id, country, date, name, description, is_state, is_religious)
VALUES
('096ca6c4-5c04-47a4-b363-4b4cc6f6b671', 'HR', '2023-01-01', 'Nova godina', '', true, false),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b672', 'HR', '2023-01-06', 'Sveta tri kralja', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b673', 'HR', '2023-04-09', 'Uskrs', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b674', 'HR', '2023-04-10', 'Uskrsni ponedjeljak', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b675', 'HR', '2023-05-01', 'Praznik rada', '', true, false),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b676', 'HR', '2023-05-30', 'Dan državnosti', '', true, false),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b677', 'HR', '2023-06-08', 'Tijelovo', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b678', 'HR', '2023-06-22', 'Dan antifašističke borbe', '', true, false),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b679', 'HR', '2023-08-05', 'Dan pobjede i domovinske zahvalnost', '', true, false),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b680', 'HR', '2023-08-15', 'Velika gospa', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b681', 'HR', '2023-11-01', 'Dan svih svetih', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b682', 'HR', '2023-11-18', 'Dan sjećanja na žrtve Domovinskog rata', '', true, false),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b683', 'HR', '2023-12-25', 'Božić', '', true, true),
('096ca6c4-5c04-47a4-b363-4b4cc6f6b684', 'HR', '2023-12-26', 'Štefanje', '', true, true);

View File

@ -2,14 +2,14 @@ version: '3.1'
services: services:
backend: backend:
image: registry.bbr-dev.info/template/backend:latest image: registry.bbr-dev.info/holiday-api/backend:latest
restart: on-failure restart: on-failure
depends_on: depends_on:
- database - database
ports: ports:
- "5281:5281" - "5281:5281"
networks: networks:
- template - holiday
env_file: env_file:
- .env.docker - .env.docker
@ -18,11 +18,11 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_USER=template - POSTGRES_USER=holiday
- POSTGRES_PASSWORD=templatePassword - POSTGRES_PASSWORD=holidayPassword
- POSTGRES_DB=template - POSTGRES_DB=holiday
networks: networks:
- template - holiday
networks: networks:
template: holiday:

View File

@ -6,6 +6,6 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_USER=template - POSTGRES_USER=holiday
- POSTGRES_PASSWORD=templatePassword - POSTGRES_PASSWORD=holidayPassword
- POSTGRES_DB=template - POSTGRES_DB=holiday

View File

@ -10,10 +10,10 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
RUN go build -tags timetzdata template RUN go build -tags timetzdata holiday-api
### Stage 2: Run ### ### Stage 2: Run ###
FROM scratch FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=go-build /root/template /usr/bin/template COPY --from=go-build /root/holiday-api /usr/bin/holiday-api
ENTRYPOINT ["template"] ENTRYPOINT ["holiday-api"]

2
go.mod
View File

@ -1,4 +1,4 @@
module template module holiday-api
go 1.19 go 1.19

159
handlers.go Normal file
View File

@ -0,0 +1,159 @@
package main
import (
"encoding/xml"
"fmt"
"github.com/google/uuid"
"holiday-api/holiday"
"log"
"net/http"
"strconv"
"time"
)
func getHolidays(service holiday.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
log.Printf("Failed parsing params: %v", err)
}
paging := holiday.Paging{
PageSize: readInt(r, "pageSize", 1000),
Page: readInt(r, "page", 0),
}
search := holiday.Search{
Country: r.Form.Get("country"),
Year: readIntNullable(r, "year"),
Date: readDateNullable(r, "date"),
RangeStart: readDateNullable(r, "rangeStart"),
RangeEnd: readDateNullable(r, "rangeEnd"),
StateHoliday: readBoolNullable(r, "stateHoliday"),
ReligiousHoliday: readBoolNullable(r, "religiousHoliday"),
}
holidays, err := service.Find(search, paging)
if err != nil {
render(w, r, 404, ErrorResponse{Created: time.Now(), Message: "failed fetching holidays"})
} else {
render(w, r, 200, mapHolidays(holidays))
}
}
}
type DateResponse struct{ time.Time }
func (d DateResponse) MarshalJSON() ([]byte, error) {
return []byte("\"" + d.String() + "\""), nil
}
func (d DateResponse) String() string {
return fmt.Sprintf(`%04d-%02d-%02d`, d.Year(), d.Month(), d.Day())
}
type ErrorResponse struct {
XMLName xml.Name `json:"-" xml:"Error"`
Created time.Time `json:"created" xml:"created"`
Message string `json:"message" xml:"message"`
Code int `json:"-" xml:"-"`
}
type HolidayResponse struct {
XMLName xml.Name `json:"-" xml:"Holidays"`
Holidays []HolidayItemResponse `json:"holidays"`
}
type HolidayItemResponse struct {
XMLName xml.Name `json:"-" xml:"Holiday"`
Id uuid.UUID `json:"id" xml:"id,attr"`
Date DateResponse `json:"date" xml:"date,attr"`
Name string `json:"name" xml:"name"`
Description string `json:"description" xml:"description,omitempty"`
IsStateHoliday bool `json:"isStateHoliday" xml:"isState,attr"`
IsReligiousHoliday bool `json:"isReligiousHoliday" xml:"isReligious,attr"`
}
type HolidaySingleResponse struct {
Id *uuid.UUID
Country string
Date DateResponse
Name string
Description string
IsStateHoliday bool
IsReligiousHoliday bool
}
type HolidaySingleRequest struct {
Id *uuid.UUID
Country string
Date DateResponse
Name string
Description string
IsStateHoliday bool
IsReligiousHoliday bool
}
func mapHolidays(holidays []holiday.Holiday) HolidayResponse {
var response = make([]HolidayItemResponse, 0, len(holidays))
for _, h := range holidays {
response = append(response, HolidayItemResponse{
Id: h.Id,
Date: DateResponse{h.Date},
Name: h.Name,
Description: h.Description,
IsStateHoliday: h.IsStateHoliday,
IsReligiousHoliday: h.IsReligiousHoliday,
})
}
return HolidayResponse{
Holidays: response,
}
}
func readDateNullable(r *http.Request, param string) *time.Time {
if r.Form.Has(param) {
var year, month, date int
count, err := fmt.Sscanf(r.Form.Get(param), "%d-%d-%d", &year, &month, &date)
if count < 3 {
log.Printf("failed reading %s: %v", param, err)
return nil
} else {
readDate := time.Date(year, time.Month(month), date, 0, 0, 0, 0, time.UTC)
return &readDate
}
} else {
return nil
}
}
func readIntNullable(r *http.Request, param string) *int {
if r.Form.Has(param) {
if value, err := strconv.Atoi(r.Form.Get(param)); err == nil {
return &value
} else {
return nil
}
} else {
return nil
}
}
func readBoolNullable(r *http.Request, param string) *bool {
if r.Form.Has(param) {
if value, err := strconv.ParseBool(r.Form.Get(param)); err == nil {
return &value
} else {
return nil
}
} else {
return nil
}
}
func readInt(r *http.Request, param string, defaultValue int) int {
if r.Form.Has(param) {
if value, err := strconv.Atoi(r.Form.Get(param)); err == nil {
return value
} else {
return defaultValue
}
} else {
return defaultValue
}
}

31
holiday/model.go Normal file
View File

@ -0,0 +1,31 @@
package holiday
import (
"github.com/google/uuid"
"time"
)
type Holiday struct {
Id uuid.UUID `db:"id"`
Country string `db:"country"`
Date time.Time `db:"date"`
Name string `db:"name"`
Description string `db:"description"`
IsStateHoliday bool `db:"is_state"`
IsReligiousHoliday bool `db:"is_religious"`
}
type Paging struct {
PageSize int
Page int
}
type Search struct {
Country string
Year *int
Date *time.Time
RangeStart *time.Time
RangeEnd *time.Time
StateHoliday *bool
ReligiousHoliday *bool
}

111
holiday/service.go Normal file
View File

@ -0,0 +1,111 @@
package holiday
import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"strconv"
"strings"
"time"
)
type Service struct {
DB *sqlx.DB
}
func (s *Service) Find(search Search, paging Paging) ([]Holiday, error) {
var holidays []Holiday
var err error
if search.Date != nil {
holidays, err = s.findByDate(*search.Date, search.StateHoliday, search.ReligiousHoliday, search.Country)
} else if search.RangeStart != nil || search.RangeEnd != nil {
holidays, err = s.findForRange(getDateOrDefault(search.RangeStart, 1000), getDateOrDefault(search.RangeEnd, 3000), search.StateHoliday, search.ReligiousHoliday, search.Country)
} else if search.Year != nil {
holidays, err = s.findByYear(*search.Year, search.StateHoliday, search.ReligiousHoliday, search.Country)
} else {
holidays, err = s.find(search.StateHoliday, search.ReligiousHoliday, search.Country)
}
if err != nil {
return nil, err
}
return s.paginate(holidays, paging), err
}
func (s *Service) FindById(id uuid.UUID) (Holiday, error) {
var holiday Holiday
return holiday, s.DB.Get(&holiday, `SELECT * FROM "holiday" WHERE "id" = $1;`, id)
}
func (s *Service) findByDate(date time.Time, isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE "date" = $1 AND country = $2 `+s.filter(isState, isReligious)+";", date, country)
}
func (s *Service) findForRange(rangeStart time.Time, rangeEnd time.Time, isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE "date" BETWEEN $1 AND $2 AND country = $3`+s.filter(isState, isReligious)+";", rangeStart, rangeEnd, country)
}
func (s *Service) findByYear(year int, isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE extract(year from "date") = $1 AND country = $2 `+s.filter(isState, isReligious)+";", year, country)
}
func (s *Service) find(isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE country = $1 `+s.filter(isState, isReligious)+";", country)
}
func (s *Service) paginate(holidays []Holiday, paging Paging) []Holiday {
start := paging.Page * paging.PageSize
end := (paging.Page + 1) * paging.PageSize
if end < len(holidays) {
return holidays[start:end]
} else if start < len(holidays) {
return holidays[start:]
} else {
return []Holiday{}
}
}
func (s *Service) filter(isState *bool, isReligious *bool) string {
var filters []string
if isState != nil {
filters = append(filters, "is_state = "+strconv.FormatBool(*isState))
}
if isReligious != nil {
filters = append(filters, "is_religious = "+strconv.FormatBool(*isReligious))
}
if len(filters) > 0 {
return " AND " + strings.Join(filters, " AND ")
} else {
return ""
}
}
func (s *Service) Update(holiday Holiday) (Holiday, error) {
_, err := s.DB.Exec(`UPDATE holiday SET "country" = $1, "name" = $2, "description" = $3, "date" = $4, "is_state"=$5, "is_religious"=$6 WHERE "id" = $7`,
&holiday.Country, &holiday.Name, &holiday.Description, &holiday.Date, &holiday.IsStateHoliday, &holiday.IsReligiousHoliday, &holiday.Id,
)
return holiday, err
}
func (s *Service) Create(holiday Holiday) (Holiday, error) {
holiday.Id = uuid.Must(uuid.NewRandom())
_, err := s.DB.Exec(`INSERT INTO holiday (id, country, name, description, date, is_state, is_religious) values ($1, $2, $3, $4, $5, $6, $7)`,
&holiday.Id, &holiday.Country, &holiday.Name, &holiday.Description, &holiday.Date, &holiday.IsStateHoliday, &holiday.IsReligiousHoliday,
)
return holiday, err
}
func (s *Service) Delete(id uuid.UUID) error {
_, err := s.DB.Exec(`DELETE FROM holiday WHERE "id" = $1`, &id)
return err
}
func getDateOrDefault(date *time.Time, year int) time.Time {
if date == nil {
return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
} else {
return *date
}
}

206
main.go
View File

@ -2,19 +2,36 @@ package main
import ( import (
"embed" "embed"
"fmt"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/google/uuid"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/lib/pq"
"holiday-api/holiday"
"holiday-api/migration"
"html/template"
"log" "log"
"net/http" "net/http"
"template/migration" "os"
"strconv"
"strings"
"time"
) )
//go:embed db/dev/*.sql //go:embed db/dev/*.sql
var devMigrations embed.FS var devMigrations embed.FS
//go:embed db/prod/*.sql
var prodMigrations embed.FS
var isDev = false
func init() { func init() {
godotenv.Load() godotenv.Load()
if strings.Contains(os.Getenv("PROFILE"), "dev") {
isDev = true
}
log.SetPrefix("") log.SetPrefix("")
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
} }
@ -24,7 +41,11 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("couldn't connect to db: %v", err) log.Fatalf("couldn't connect to db: %v", err)
} }
if err := migration.InitializeMigrations(client, devMigrations); err != nil { migrationFolder := prodMigrations
if isDev {
migrationFolder = devMigrations
}
if err := migration.InitializeMigrations(client, migrationFolder); err != nil {
log.Fatalf("couldn't execute migrations: %v", err) log.Fatalf("couldn't execute migrations: %v", err)
} }
@ -34,16 +55,183 @@ func main() {
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Get("/", helloWorld) holidayService := holiday.Service{client}
r.Get("/api/v1/holidays", getHolidays(holidayService))
r.Mount("/admin", adminDashboard(holidayService))
log.Fatal(http.ListenAndServe(":5281", r)) log.Fatal(http.ListenAndServe(":5281", r))
} }
func helloWorld(w http.ResponseWriter, r *http.Request) { func adminDashboard(service holiday.Service) http.Handler {
response := struct { r := chi.NewRouter()
Title string `json:"title"`
Message string `json:"message"`
}{Title: "hello world", Message: "this is an example of response"}
render(w, r, http.StatusOK, response) credentials := loadCredentials()
templates, err := template.New("").Funcs(template.FuncMap{
"boolcmp": func(value *bool, expected string) bool {
if value == nil {
return expected == "nil"
} else {
return (*value && expected == "true") || (!(*value) && expected == "false")
}
},
"deferint": func(value *int) int { return *value },
}).ParseFiles("templates/index.gohtml", "templates/holiday.gohtml", "templates/error.gohtml")
if err != nil {
log.Fatalf("couldn't load templates: %v", err)
}
r.Use(middleware.BasicAuth("/admin", credentials))
r.Get("/holiday", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
id := r.FormValue("id")
response := HolidaySingleResponse{
Id: nil,
Country: "",
Date: DateResponse{time.Now()},
Name: "",
Description: "",
IsStateHoliday: true,
IsReligiousHoliday: false,
}
if id != "" {
if h, err := service.FindById(uuid.Must(uuid.Parse(id))); err == nil {
response = HolidaySingleResponse{
Id: &h.Id,
Country: h.Country,
Date: DateResponse{h.Date},
Name: h.Name,
Description: h.Description,
IsStateHoliday: h.IsStateHoliday,
IsReligiousHoliday: h.IsReligiousHoliday,
}
}
}
renderTemplate(w, r, 200, templates, "holiday.gohtml", response)
})
r.Post("/holiday/delete", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var id uuid.UUID
if i := r.FormValue("id"); i != "" {
id = uuid.MustParse(i)
} else {
log.Printf("Failed deleting holiday: missing id", err)
w.WriteHeader(500)
w.Write([]byte("There was an error"))
}
err := service.Delete(id)
if err != nil {
log.Printf("Failed deleting holiday: %v", err)
w.WriteHeader(500)
w.Write([]byte("There was an error"))
} else {
w.Header().Add("Location", "/admin")
w.WriteHeader(http.StatusSeeOther)
w.Write([]byte{})
}
})
r.Post("/holiday", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var id *uuid.UUID = nil
if i := r.FormValue("id"); i != "" {
temp := uuid.MustParse(i)
id = &temp
}
name := strings.TrimSpace(r.FormValue("name"))
description := strings.TrimSpace(r.FormValue("description"))
isStateHoliday := parseCheckbox(r.FormValue("stateHoliday"))
isReligiousHoliday := parseCheckbox(r.FormValue("religiousHoliday"))
date := parseDate(r.FormValue("date"))
country := r.FormValue("country")
hol := holiday.Holiday{
Country: country,
Date: date,
Name: name,
Description: description,
IsStateHoliday: isStateHoliday,
IsReligiousHoliday: isReligiousHoliday,
}
var err error
if id != nil {
hol.Id = *id
hol, err = service.Update(hol)
} else {
hol, err = service.Create(hol)
}
if err != nil {
log.Printf("Failed updating or creating holiday: %v", err)
w.WriteHeader(500)
w.Write([]byte("There was an error"))
} else {
w.Header().Add("Location", "/admin/holiday?id="+hol.Id.String())
w.WriteHeader(http.StatusSeeOther)
w.Write([]byte{})
}
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
country := r.FormValue("country")
if country == "" {
country = "HR"
}
var stateHoliday *bool = nil
if h := r.FormValue("stateHoliday"); h != "" {
if hol, err := strconv.ParseBool(h); err == nil {
stateHoliday = &hol
}
}
var religiousHoliday *bool = nil
if h := r.FormValue("religiousHoliday"); h != "" {
if hol, err := strconv.ParseBool(h); err == nil {
religiousHoliday = &hol
}
}
year := time.Now().Year()
if y := r.FormValue("year"); y != "" {
if yr, err := strconv.ParseInt(y, 10, 64); err == nil {
year = int(yr)
}
}
search := holiday.Search{Year: &year, Country: country, StateHoliday: stateHoliday, ReligiousHoliday: religiousHoliday}
holidays, _ := service.Find(search, holiday.Paging{PageSize: 100})
holidayResponse := mapHolidays(holidays)
response := map[string]any{}
response["holidays"] = holidayResponse
response["search"] = search
renderTemplate(w, r, 200, templates, "index.gohtml", response)
})
return r
}
func parseDate(date string) time.Time {
var year, month, day int
fmt.Sscanf(date, "%d-%d-%d", &year, &month, &day)
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
func parseCheckbox(value string) bool {
if value != "" {
return true
}
return false
}
func loadCredentials() map[string]string {
credentials := envMustExist("AUTH_KEY")
values := strings.Split(credentials, ":")
return map[string]string{values[0]: values[1]}
} }

View File

@ -10,17 +10,17 @@ setup:
go get go get
docker-dev: docker-dev:
docker image build -t registry.bbr-dev.info/template/backend:$(VERSION)-dev . docker image build -t registry.bbr-dev.info/holiday-api/backend:$(VERSION)-dev .
docker tag registry.bbr-dev.info/template/backend:$(VERSION)-dev registry.bbr-dev.info/template/backend:latest-dev docker tag registry.bbr-dev.info/holiday-api/backend:$(VERSION)-dev registry.bbr-dev.info/holiday-api/backend:latest-dev
docker image push registry.bbr-dev.info/template/backend:$(VERSION)-dev docker image push registry.bbr-dev.info/holiday-api/backend:$(VERSION)-dev
docker image push registry.bbr-dev.info/template/backend:latest-dev docker image push registry.bbr-dev.info/holiday-api/backend:latest-dev
docker-prod: docker-prod:
docker image build -t registry.bbr-dev.info/template/backend:$(VERSION) . docker image build -t registry.bbr-dev.info/holiday-api/backend:$(VERSION) .
docker tag registry.bbr-dev.info/template/backend:$(VERSION) registry.bbr-dev.info/template/backend:latest docker tag registry.bbr-dev.info/holiday-api/backend:$(VERSION) registry.bbr-dev.info/holiday-api/backend:latest
docker image push registry.bbr-dev.info/template/backend:$(VERSION) docker image push registry.bbr-dev.info/holiday-api/backend:$(VERSION)
docker image push registry.bbr-dev.info/template/backend:latest docker image push registry.bbr-dev.info/holiday-api/backend:latest
release: release:
git tag $(VERSION) git tag $(VERSION)
@ -30,4 +30,4 @@ test:
go test ./... go test ./...
clean: clean:
rm -rf template rm -rf holiday-api

View File

@ -1,21 +1,58 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"encoding/xml"
"html/template"
"log" "log"
"net/http" "net/http"
) )
func renderTemplate[T any](w http.ResponseWriter, r *http.Request, status int, templates *template.Template, template string, response T) error {
buffer := bytes.Buffer{}
if err := templates.Lookup(template).Execute(&buffer, response); err == nil {
w.Header().Add("content-type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = w.Write(buffer.Bytes())
return err
} else {
return onError(w, err)
}
}
func onError(w http.ResponseWriter, err error) error {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("failed writing response: %v", err)
_, _ = w.Write([]byte{})
return err
}
func render[T any](w http.ResponseWriter, r *http.Request, status int, response T) error { func render[T any](w http.ResponseWriter, r *http.Request, status int, response T) error {
switch r.Header.Get("accept") {
case "application/xml":
fallthrough
case "text/xml":
if body, err := xml.MarshalIndent(response, "", " "); err == nil {
// we return the same type as requested
w.Header().Add("content-type", r.Header.Get("accept")+"; charset=utf-8")
w.WriteHeader(status)
_, err = w.Write([]byte(xml.Header))
_, err = w.Write(body)
return err
} else {
return onError(w, err)
}
case "application/json":
fallthrough
default:
if body, err := json.MarshalIndent(response, "", " "); err == nil { if body, err := json.MarshalIndent(response, "", " "); err == nil {
w.Header().Add("content-type", "application/json") w.Header().Add("content-type", "application/json; charset=utf-8")
w.WriteHeader(status) w.WriteHeader(status)
_, err = w.Write(body) _, err = w.Write(body)
return err return err
} else { } else {
w.WriteHeader(http.StatusInternalServerError) return onError(w, err)
log.Printf("couldn't parse response") }
_, err = w.Write([]byte{})
return err
} }
} }

5
templates/error.gohtml Normal file
View File

@ -0,0 +1,5 @@
<html>
<body>
<h1>Test for error</h1>
</body>
</html>

44
templates/holiday.gohtml Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Holiday-api holiday</title>
</head>
<body>
<h1>Holiday-api - {{if .Id}}Updating{{else}}Creating{{end}}</h1>
<p>Page for creating or editing holidays</p>
<a href="/admin">Back</a>
<form method="post" action="/admin/holiday">
{{if .Id}}
<input type="hidden" id="id" name="id" value="{{.Id}}">
{{end}}
<label for="country">Country</label>
<select id="country" name="country">
<option value="HR" {{if eq .Country "HR"}}selected{{end}}>Croatia</option>
<option value="US" {{if eq .Country "US"}}selected{{end}}>USA</option>
<option value="GB" {{if eq .Country "GB"}}selected{{end}}>Great Britain</option>
<option value="FR" {{if eq .Country "FR"}}selected{{end}}>France</option>
</select>
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}">
<label for="description">Description</label>
<textarea id="description" name="description">{{.Description}}</textarea>
<label for="date">Date</label>
<input type="date" id="date" name="date" value="{{.Date.String}}">
<label for="state-holiday">State holiday</label>
<input type="checkbox" id="state-holiday" name="stateHoliday" {{if .IsStateHoliday}}checked{{end}}>
<label for="religious-holiday">Religious holiday</label>
<input type="checkbox" id="religious-holiday" name="religiousHoliday" {{if .IsReligiousHoliday}}checked{{end}}>
<button type="submit">{{if .Id}}Update{{else}}Create{{end}}</button>
</form>
</body>
</html>

102
templates/index.gohtml Normal file
View File

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Holiday-api</title>
</head>
<body>
<h1>Holiday-api</h1>
<p>Welcome to admin interface for holiday api</p>
<div>
<a href="/admin/holiday">Add a holiday</a>
</div>
<div>
<form id="filter-holidays" action="/admin" method="get">
<label for="country">Country</label>
<select id="country" name="country">
<option value="HR" {{if eq .search.Country "HR"}}selected{{end}}>Croatia</option>
<option value="GB" {{if eq .search.Country "GB"}}selected{{end}}>Great Britain</option>
<option value="US" {{if eq .search.Country "US"}}selected{{end}}>USA</option>
<option value="FR" {{if eq .search.Country "FR"}}selected{{end}}>France</option>
</select>
<label for="year">Year</label>
<select id="year" name="year">
<option value="2020" {{if eq (deferint .search.Year) 2020}}selected{{end}}>2020</option>
<option value="2021" {{if eq (deferint .search.Year) 2021}}selected{{end}}>2021</option>
<option value="2022" {{if eq (deferint .search.Year) 2022}}selected{{end}}>2022</option>
<option value="2023" {{if eq (deferint .search.Year) 2023}}selected{{end}}>2023</option>
<option value="2024" {{if eq (deferint .search.Year) 2024}}selected{{end}}>2024</option>
<option value="2025" {{if eq (deferint .search.Year) 2025}}selected{{end}}>2025</option>
<option value="2026" {{if eq (deferint .search.Year) 2026}}selected{{end}}>2026</option>
<option value="2027" {{if eq (deferint .search.Year) 2027}}selected{{end}}>2027</option>
</select>
<div>
<label>State holiday</label>
<label for="state-holiday-true">True</label>
<input type="radio" value="true" {{if boolcmp .search.StateHoliday "true"}}checked{{end}} id="state-holiday-true" name="stateHoliday">
<label for="state-holiday-false">False</label>
<input type="radio" value="false" {{if boolcmp .search.StateHoliday "false"}}checked{{end}} id="state-holiday-false" name="stateHoliday">
<label for="state-holiday-any">Any</label>
<input type="radio" value="" {{if boolcmp .search.StateHoliday "nil"}}checked{{end}} id="state-holiday-any" name="stateHoliday">
</div>
<div>
<label>Religious holiday</label>
<label for="religious-holiday-true">True</label>
<input type="radio" value="true" {{if boolcmp .search.ReligiousHoliday "true"}}checked{{end}} id="religious-holiday-true" name="religiousHoliday">
<label for="religious-holiday-false">False</label>
<input type="radio" value="false" {{if boolcmp .search.ReligiousHoliday "false"}}checked{{end}} id="religious-holiday-false" name="religiousHoliday">
<label for="religious-holiday-any">Any</label>
<input type="radio" value="" {{if boolcmp .search.ReligiousHoliday "nil"}}checked{{end}} id="religious-holiday-any" name="religiousHoliday">
</div>
<button type="submit">Search</button>
</form>
</div>
<div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Date</th>
<th>State holiday</th>
<th>Religious holiday</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $entry := .holidays.Holidays}}
<tr>
<td>{{$entry.Name}}</td>
<td>{{$entry.Description}}</td>
<td>{{$entry.Date.Format "2006-01-02"}}</td>
<td>{{$entry.IsStateHoliday}}</td>
<td>{{$entry.IsReligiousHoliday}}</td>
<td>
<a href="/admin/holiday?id={{$entry.Id}}">Edit</a>
<form action="/admin/holiday/delete" method="post">
<input id="id" type="hidden" name="id" value="{{$entry.Id}}">
<button type="submit">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</body>
</html>