Initial implementation
This commit is contained in:
parent
bb4f465321
commit
2172b8232e
|
@ -1,3 +1,3 @@
|
|||
.idea/**
|
||||
template
|
||||
holiday-api
|
||||
.env
|
106
README.md
106
README.md
|
@ -1,22 +1,96 @@
|
|||
# go-web-app-template
|
||||
# Holiday api
|
||||
|
||||
Basic template for creating go applications
|
||||
Simple api used for tracking holidays
|
||||
|
||||
Includes:
|
||||
* dotenv
|
||||
* pq
|
||||
* sqlx
|
||||
* chi
|
||||
* google.uuid
|
||||
## Endpoints
|
||||
|
||||
To start using make sure to setup either environment variables listed
|
||||
below, or create .env file and copy variables below
|
||||
Fetching is done via `GET /api/v1/holidays` endpoint
|
||||
|
||||
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
|
||||
PSQL_USER=template
|
||||
PSQL_PASSWORD=templatePassword
|
||||
PSQL_DB=template
|
||||
[{
|
||||
id: string;
|
||||
date: string(ISO 8601);
|
||||
name: string;
|
||||
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
|
||||
```
|
|
@ -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);
|
||||
|
||||
|
||||
|
|
@ -2,14 +2,14 @@ version: '3.1'
|
|||
|
||||
services:
|
||||
backend:
|
||||
image: registry.bbr-dev.info/template/backend:latest
|
||||
image: registry.bbr-dev.info/holiday-api/backend:latest
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
- database
|
||||
ports:
|
||||
- "5281:5281"
|
||||
networks:
|
||||
- template
|
||||
- holiday
|
||||
env_file:
|
||||
- .env.docker
|
||||
|
||||
|
@ -18,11 +18,11 @@ services:
|
|||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=template
|
||||
- POSTGRES_PASSWORD=templatePassword
|
||||
- POSTGRES_DB=template
|
||||
- POSTGRES_USER=holiday
|
||||
- POSTGRES_PASSWORD=holidayPassword
|
||||
- POSTGRES_DB=holiday
|
||||
networks:
|
||||
- template
|
||||
- holiday
|
||||
|
||||
networks:
|
||||
template:
|
||||
holiday:
|
|
@ -6,6 +6,6 @@ services:
|
|||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=template
|
||||
- POSTGRES_PASSWORD=templatePassword
|
||||
- POSTGRES_DB=template
|
||||
- POSTGRES_USER=holiday
|
||||
- POSTGRES_PASSWORD=holidayPassword
|
||||
- POSTGRES_DB=holiday
|
|
@ -10,10 +10,10 @@ COPY go.mod go.sum ./
|
|||
RUN go mod download
|
||||
COPY . .
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -tags timetzdata template
|
||||
RUN go build -tags timetzdata holiday-api
|
||||
|
||||
### Stage 2: Run ###
|
||||
FROM scratch
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=go-build /root/template /usr/bin/template
|
||||
ENTRYPOINT ["template"]
|
||||
COPY --from=go-build /root/holiday-api /usr/bin/holiday-api
|
||||
ENTRYPOINT ["holiday-api"]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
206
main.go
|
@ -2,19 +2,36 @@ package main
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
"holiday-api/holiday"
|
||||
"holiday-api/migration"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"template/migration"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed db/dev/*.sql
|
||||
var devMigrations embed.FS
|
||||
|
||||
//go:embed db/prod/*.sql
|
||||
var prodMigrations embed.FS
|
||||
|
||||
var isDev = false
|
||||
|
||||
func init() {
|
||||
godotenv.Load()
|
||||
if strings.Contains(os.Getenv("PROFILE"), "dev") {
|
||||
isDev = true
|
||||
}
|
||||
log.SetPrefix("")
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
}
|
||||
|
@ -24,7 +41,11 @@ func main() {
|
|||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -34,16 +55,183 @@ func main() {
|
|||
r.Use(middleware.Logger)
|
||||
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))
|
||||
}
|
||||
|
||||
func helloWorld(w http.ResponseWriter, r *http.Request) {
|
||||
response := struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}{Title: "hello world", Message: "this is an example of response"}
|
||||
func adminDashboard(service holiday.Service) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
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]}
|
||||
}
|
||||
|
|
18
makefile
18
makefile
|
@ -10,17 +10,17 @@ setup:
|
|||
go get
|
||||
|
||||
docker-dev:
|
||||
docker image build -t registry.bbr-dev.info/template/backend:$(VERSION)-dev .
|
||||
docker tag registry.bbr-dev.info/template/backend:$(VERSION)-dev registry.bbr-dev.info/template/backend:latest-dev
|
||||
docker image push registry.bbr-dev.info/template/backend:$(VERSION)-dev
|
||||
docker image push registry.bbr-dev.info/template/backend:latest-dev
|
||||
docker image build -t registry.bbr-dev.info/holiday-api/backend:$(VERSION)-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/holiday-api/backend:$(VERSION)-dev
|
||||
docker image push registry.bbr-dev.info/holiday-api/backend:latest-dev
|
||||
|
||||
|
||||
docker-prod:
|
||||
docker image build -t registry.bbr-dev.info/template/backend:$(VERSION) .
|
||||
docker tag registry.bbr-dev.info/template/backend:$(VERSION) registry.bbr-dev.info/template/backend:latest
|
||||
docker image push registry.bbr-dev.info/template/backend:$(VERSION)
|
||||
docker image push registry.bbr-dev.info/template/backend:latest
|
||||
docker image build -t registry.bbr-dev.info/holiday-api/backend:$(VERSION) .
|
||||
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/holiday-api/backend:$(VERSION)
|
||||
docker image push registry.bbr-dev.info/holiday-api/backend:latest
|
||||
|
||||
release:
|
||||
git tag $(VERSION)
|
||||
|
@ -30,4 +30,4 @@ test:
|
|||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf template
|
||||
rm -rf holiday-api
|
||||
|
|
53
render.go
53
render.go
|
@ -1,21 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func render[T any](w http.ResponseWriter, r *http.Request, status int, response T) error {
|
||||
if body, err := json.MarshalIndent(response, "", " "); err == nil {
|
||||
w.Header().Add("content-type", "application/json")
|
||||
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(body)
|
||||
_, err = w.Write(buffer.Bytes())
|
||||
return err
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Printf("couldn't parse response")
|
||||
_, err = w.Write([]byte{})
|
||||
return err
|
||||
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 {
|
||||
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 {
|
||||
w.Header().Add("content-type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
} else {
|
||||
return onError(w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<html>
|
||||
<body>
|
||||
<h1>Test for error</h1>
|
||||
</body>
|
||||
</html>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue