This commit is contained in:
Gustavo Maronato 2023-08-17 14:14:59 -03:00
commit 997ab6b59f
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
50 changed files with 4356 additions and 0 deletions

164
.dockerignore Normal file
View File

@ -0,0 +1,164 @@
# ---> DB
*.db
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
/goshort
# ---> Node
#
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# >> Git and github
.github
.git

160
.gitignore vendored Normal file
View File

@ -0,0 +1,160 @@
# ---> DB
*.db
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
/goshort
# ---> Node
#
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

0
Dockerfile Normal file
View File

0
LICENSE Normal file
View File

0
Makefile Normal file
View File

0
README.md Normal file
View File

115
cmd/dev/dev.go Normal file
View File

@ -0,0 +1,115 @@
package devcmd
import (
"context"
"flag"
"fmt"
"net/http"
"git.maronato.dev/maronato/goshort/cmd/shared"
"git.maronato.dev/maronato/goshort/internal/config"
"git.maronato.dev/maronato/goshort/internal/server"
apiserver "git.maronato.dev/maronato/goshort/internal/server/api"
devuiserver "git.maronato.dev/maronato/goshort/internal/server/devui"
healthcheckserver "git.maronato.dev/maronato/goshort/internal/server/healthcheck"
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
memorystorage "git.maronato.dev/maronato/goshort/internal/storage/memory"
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/sync/errgroup"
)
func New(cfg *config.Config) *ffcli.Command {
// Create the flagset and register the flags.
fs := flag.NewFlagSet("goshort dev", flag.ContinueOnError)
shared.RegisterBaseFlags(fs, cfg)
shared.RegisterServerFlags(fs, cfg)
// Create the command and options
cmd := shared.NewCommand(cfg, exec)
opts := shared.NewSharedCmdOptions()
// Register the UI-port flag
fs.StringVar(&cfg.UIPort, "ui-port", config.DefaultUIPort, "port to listen on for the UI")
// Return the ffcli command.
return &ffcli.Command{
Name: "dev",
ShortUsage: "goshort dev [flags]",
ShortHelp: "Starts the API and frontend servers in development mode",
FlagSet: fs,
Exec: cmd.Exec,
Options: opts,
}
}
func exec(ctx context.Context, cfg *config.Config) error {
eg, egCtx := errgroup.WithContext(ctx)
// Start the API server
eg.Go(func() error {
return serveAPI(egCtx, cfg)
})
// Start the UI server
eg.Go(func() error {
return serveUI(egCtx, cfg)
})
// Wait for the context to be done
if err := eg.Wait(); err != nil {
return fmt.Errorf("error during dev servers execution, %w", err)
}
return nil
}
func serveAPI(ctx context.Context, cfg *config.Config) error {
// Create the new server
server := server.NewServer(cfg)
// Create services
shortStorage := memorystorage.NewMemoryStorage()
shortService := shortservice.NewShortService(shortStorage)
// Create handlers
apiHandler := apiserver.NewAPIHandler(shortService)
shortHandler := shortserver.NewShortHandler(shortService)
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
// Create routers
apiRouter := apiserver.NewAPIRouter(apiHandler)
shortRouter := shortserver.NewShortRouter(shortHandler)
healthcheckRouter := healthcheckserver.NewHealthcheckRouter(healthcheckHandler)
// Create the root URL handler by chaining short and NotFound handlers
chainedRouter := handlerutils.NewChainedHandler(shortRouter, http.NotFoundHandler())
// Configure app routes
server.Mux.Group(func(r chi.Router) {
// Set CORS headers for API routes in development mode
r.Use(middleware.SetHeader("Access-Control-Allow-Origin", "*"))
r.Mount("/api", apiRouter)
})
server.Mux.Mount("/healthz", healthcheckRouter)
server.Mux.Mount("/", chainedRouter)
if err := server.ListenAndServe(ctx); err != nil {
return fmt.Errorf("error during API server execution, %w", err)
}
return nil
}
func serveUI(ctx context.Context, cfg *config.Config) error {
// Create the UI server
server := devuiserver.NewServer(cfg)
if err := server.ListenAndServe(ctx); err != nil {
return fmt.Errorf("error during UI server execution, %w", err)
}
return nil
}

View File

@ -0,0 +1,66 @@
package healthcheckcmd
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"git.maronato.dev/maronato/goshort/cmd/shared"
"git.maronato.dev/maronato/goshort/internal/config"
"github.com/peterbourgon/ff/v3/ffcli"
)
func New(cfg *config.Config) *ffcli.Command {
// Create the flagset and register the flags.
fs := flag.NewFlagSet("goshort healthcheck", flag.ContinueOnError)
shared.RegisterBaseFlags(fs, cfg)
// Create the command and options
cmd := shared.NewCommand(cfg, exec)
opts := shared.NewSharedCmdOptions()
// Return the ffcli command.
return &ffcli.Command{
Name: "healthcheck",
ShortUsage: "goshort healthcheck [flags]",
ShortHelp: "Calls the healthcheck endpoint of the server",
FlagSet: fs,
Exec: cmd.Exec,
Options: opts,
}
}
// exec makes a request to the healthcheck endpoint.
// If the request fails, return an error
// Otherwise, return nil.
func exec(ctx context.Context, cfg *config.Config) error {
addr := url.URL{
Host: net.JoinHostPort(cfg.Host, cfg.Port),
Scheme: "http",
Path: "/healthz",
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr.String(), http.NoBody)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf( //nolint:goerr113 // We don't need to wrap this error
"healthcheck endpoint returned status code %d",
resp.StatusCode,
)
}
return nil
}

93
cmd/main.go Normal file
View File

@ -0,0 +1,93 @@
package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
devcmd "git.maronato.dev/maronato/goshort/cmd/dev"
healthcheckcmd "git.maronato.dev/maronato/goshort/cmd/healthcheck"
servecmd "git.maronato.dev/maronato/goshort/cmd/serve"
"git.maronato.dev/maronato/goshort/internal/config"
"github.com/peterbourgon/ff/v3/ffcli"
)
func Run() {
// Create the application-wide context, and
// implement graceful shutdown.
ctx, cancel := context.WithCancel(context.Background())
trapSignalsCrossPlatform(cancel)
// Create the root command and register subcommands.
rootCmd, cfg := newRootCmd()
rootCmd.Subcommands = []*ffcli.Command{
servecmd.New(cfg),
healthcheckcmd.New(cfg),
}
// Look for the env ENV_DOCKER=true to disable the dev command
if os.Getenv("ENV_DOCKER") != "true" {
rootCmd.Subcommands = append(rootCmd.Subcommands, devcmd.New(cfg))
}
// Parse the command-line arguments.
if err := rootCmd.Parse(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Validate config
if err := config.Validate(cfg); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Run the command.
if err := rootCmd.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
// newRootCmd constructs a root command from the provided options.
func newRootCmd() (*ffcli.Command, *config.Config) {
var cfg config.Config
fs := flag.NewFlagSet("goshort", flag.ContinueOnError)
return &ffcli.Command{
Name: "goshort",
ShortUsage: "goshort <subcommand> [flags]",
ShortHelp: "goshort is a tiny URL shortener",
FlagSet: fs,
Exec: func(ctx context.Context, args []string) error {
return flag.ErrHelp
},
}, &cfg
}
// https://github.com/caddyserver/caddy/blob/fbb0ecfa322aa7710a3448453fd3ae40f037b8d1/sigtrap.go#L37
// trapSignalsCrossPlatform captures SIGINT or interrupt (depending
// on the OS), which initiates a graceful shutdown. A second SIGINT
// or interrupt will forcefully exit the process immediately.
func trapSignalsCrossPlatform(cancel context.CancelFunc) {
go func() {
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGINT)
for i := 0; true; i++ {
<-shutdown
if i > 0 {
fmt.Printf("\nForce quit\n")
os.Exit(1)
}
fmt.Printf("\nGracefully shutting down. Press Ctrl+C again to force quit\n")
cancel()
}
}()
}

76
cmd/serve/serve.go Normal file
View File

@ -0,0 +1,76 @@
package servecmd
import (
"context"
"flag"
"fmt"
"git.maronato.dev/maronato/goshort/cmd/shared"
"git.maronato.dev/maronato/goshort/internal/config"
"git.maronato.dev/maronato/goshort/internal/server"
apiserver "git.maronato.dev/maronato/goshort/internal/server/api"
healthcheckserver "git.maronato.dev/maronato/goshort/internal/server/healthcheck"
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
staticssterver "git.maronato.dev/maronato/goshort/internal/server/static"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
memorystorage "git.maronato.dev/maronato/goshort/internal/storage/memory"
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
"github.com/peterbourgon/ff/v3/ffcli"
)
func New(cfg *config.Config) *ffcli.Command {
// Create the flagset and register the flags.
fs := flag.NewFlagSet("goshort serve", flag.ContinueOnError)
shared.RegisterBaseFlags(fs, cfg)
shared.RegisterServerFlags(fs, cfg)
// Create the command and options
cmd := shared.NewCommand(cfg, exec)
opts := shared.NewSharedCmdOptions()
// Return the ffcli command.
return &ffcli.Command{
Name: "serve",
ShortUsage: "goshort serve [flags]",
ShortHelp: "Starts the API server with embedded frontend",
FlagSet: fs,
Exec: cmd.Exec,
Options: opts,
}
}
func exec(ctx context.Context, cfg *config.Config) error {
// Create the new server
server := server.NewServer(cfg)
// Create services
shortStorage := memorystorage.NewMemoryStorage()
shortService := shortservice.NewShortService(shortStorage)
// Create handlers
apiHandler := apiserver.NewAPIHandler(shortService)
shortHandler := shortserver.NewShortHandler(shortService)
staticHandler := staticssterver.NewStaticHandler(cfg)
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
// Create routers
apiRouter := apiserver.NewAPIRouter(apiHandler)
shortRouter := shortserver.NewShortRouter(shortHandler)
staticRouter := staticssterver.NewStaticRouter(staticHandler)
healthcheckRouter := healthcheckserver.NewHealthcheckRouter(healthcheckHandler)
// Create the root URL handler by chaining short and static routers
chainedRouter := handlerutils.NewChainedHandler(shortRouter, staticRouter)
// Configure app routes
server.Mux.Mount("/api", apiRouter)
server.Mux.Mount("/healthz", healthcheckRouter)
server.Mux.Mount("/", chainedRouter)
if err := server.ListenAndServe(ctx); err != nil {
return fmt.Errorf("rrror during server execution, %w", err)
}
return nil
}

49
cmd/shared/shared.go Normal file
View File

@ -0,0 +1,49 @@
package shared
import (
"context"
"flag"
"fmt"
"strings"
"git.maronato.dev/maronato/goshort/internal/config"
"github.com/peterbourgon/ff/v3"
)
type Command struct {
// cfg is the command config populated by Parse.
cfg *config.Config
// exec is the command execution function.
exec func(context.Context, *config.Config) error
}
func NewCommand(cfg *config.Config, exec func(context.Context, *config.Config) error) *Command {
return &Command{
cfg: cfg,
exec: exec,
}
}
func (c *Command) Exec(ctx context.Context, _ []string) error {
return c.exec(ctx, c.cfg)
}
func NewSharedCmdOptions() []ff.Option {
return []ff.Option{
ff.WithEnvVarPrefix("GOSHORT"),
}
}
func RegisterBaseFlags(fs *flag.FlagSet, cfg *config.Config) {
fs.BoolVar(&cfg.Debug, "debug", config.DefaultDebug, "enable debug mode")
fs.StringVar(&cfg.Host, "host", config.DefaultHost, "host to listen on")
fs.StringVar(&cfg.Port, "port", config.DefaultPort, "port to listen on")
}
func RegisterServerFlags(fs *flag.FlagSet, cfg *config.Config) {
fs.StringVar(&cfg.RedisURL, "redis-url", config.DefaultRedisURL, "connection string for redis")
dbTypeString := fmt.Sprintf("type of database to use [%s]", strings.Join(config.DBTypes[:], ", "))
fs.StringVar(&cfg.DBType, "db-type", config.DefaultDBType, dbTypeString)
fs.StringVar(&cfg.DBURL, "db", config.DefaultDBURL, "database connection string or sqlite db path")
fs.DurationVar(&cfg.SessionDuration, "session-duration", config.DefaultSessionDuration, "session duration")
}

0
docker-compose.yml Normal file
View File

19
frontend/embed.go Normal file
View File

@ -0,0 +1,19 @@
package frontend
import (
"embed"
"fmt"
"io/fs"
)
//go:embed all:dist
var assets embed.FS
func Assets() (fs.FS, error) {
assetFS, err := fs.Sub(assets, "dist")
if err != nil {
return nil, fmt.Errorf("error getting assets: %w", err)
}
return assetFS, nil
}

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2403
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "goshort",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

42
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,42 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
const incrCount = () => {
setCount(count + 1)
fetch(import.meta.env.VITE_API_URL).then((res) => {
console.log(res)
})
}
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={incrCount}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

69
frontend/src/index.css Normal file
View File

@ -0,0 +1,69 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

33
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

12
go.mod Normal file
View File

@ -0,0 +1,12 @@
module git.maronato.dev/maronato/goshort
go 1.20
require (
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/render v1.0.3
github.com/peterbourgon/ff/v3 v3.4.0
golang.org/x/sync v0.3.0
)
require github.com/ajg/form v1.5.1 // indirect

10
go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=

8
goshort.go Normal file
View File

@ -0,0 +1,8 @@
package main
import "git.maronato.dev/maronato/goshort/cmd"
func main() {
// Run root command
cmd.Run()
}

109
internal/config/config.go Normal file
View File

@ -0,0 +1,109 @@
package config
import (
"fmt"
"net"
"net/url"
"time"
"git.maronato.dev/maronato/goshort/internal/errs"
)
const (
DBTypeMemory = "memory"
DBTypeSQLite = "sqlite"
DBTypePostgres = "postgres"
)
var DBTypes = [...]string{
DBTypeMemory,
DBTypeSQLite,
DBTypePostgres,
}
const (
// DefaultRedisURL is the default connection string for redis.
DefaultRedisURL = "redis://localhost:6379"
// DefaultDBType is the default type of database to use.
DefaultDBType = DBTypeSQLite
// DefaultDBURL is the default connection string for the database.
DefaultDBURL = "goshort.db"
// DefaultPort is the default port to listen on.
DefaultPort = "8080"
// DefaultHost is the default host to listen on.
DefaultHost = "0.0.0.0"
// DefaultUIPort is the default port to listen on for the UI.
DefaultUIPort = "3000"
// DefaultDebug is the default debug mode.
DefaultDebug = false
// DefaultSessionDuration is the default session duration.
DefaultSessionDuration = 7 * 24 * time.Hour
)
const (
// ReadTimeout is the maximum duration for reading the entire
// request, including the body.
ReadTimeout = 5 * time.Second
// WriteTimeout is the maximum duration before timing out
// writes of the response.
WriteTimeout = 10 * time.Second
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled.
IdleTimeout = 30 * time.Second
// ReadHeaderTimeout is the amount of time allowed to read
// request headers.
ReadHeaderTimeout = 2 * time.Second
// RequestTimeout is the maximum duration for the entire
// request.
RequestTimeout = 7 * 24 * time.Hour
)
// Config defines the default configuration for the backend.
type Config struct {
// Prod is a flag that indicates if the server is running in production mode.
Prod bool
// Debug is a flag that indicates if the server is running in debug mode.
Debug bool `default:"false" mapstructure:"debug"`
// Host is the host to listen on.
Host string `default:"localhost" mapstructure:"host"`
// Port is the port to listen on.
Port string `default:"8080" mapstructure:"port"`
// UiPort is the port to listen on for the UI.
UIPort string `default:"3000" mapstructure:"ui-port"`
// RedisURL is the connection string for redis.
RedisURL string `default:"redis://localhost:6379" mapstructure:"redis-url"`
// DBType is the type of database to use.
DBType string `default:"sqlite" mapstructure:"db-type"`
// DBURL is the connection string for the database.
DBURL string `default:"shortener.db" mapstructure:"db-url"`
// SessionDuration is the duration of the session.
SessionDuration time.Duration `default:"168h" mapstructure:"session-duration"`
}
func Validate(cfg *Config) error {
// Host and port have to be valid.
if _, err := url.ParseRequestURI("http://" + net.JoinHostPort(cfg.Host, cfg.Port)); err != nil {
return errs.Error(errs.ErrInvalidConfig, fmt.Sprintf("invalid host and/or port: %s", err))
}
// UI port has to be valid.
if cfg.UIPort != "" {
if _, err := url.ParseRequestURI("http://" + net.JoinHostPort(cfg.Host, cfg.UIPort)); err != nil {
return errs.Error(errs.ErrInvalidConfig, fmt.Sprintf("invalid UI port: %s", err))
}
}
if cfg.DBType != "" {
// DB type has to be valid.
valid := false
for _, dbType := range DBTypes {
if cfg.DBType == dbType {
valid = true
break
}
}
if !valid {
return errs.Error(errs.ErrInvalidConfig, fmt.Sprintf("invalid database type: %s", cfg.DBType))
}
}
return nil
}

21
internal/errs/errors.go Normal file
View File

@ -0,0 +1,21 @@
package errs
import (
"errors"
"fmt"
)
var (
// ErrInvalidConfig is returned when the configuration is invalid.
ErrInvalidConfig = errors.New("invalid configuration")
// ErrInvalidShort
ErrInvalidShort = errors.New("invalid short")
// ErrShortDoesNotExist
ErrShortDoesNotExist = errors.New("short does not exist")
// ErrShortExists
ErrShortExists = errors.New("short already exists")
)
func Error(err error, msg string) error {
return fmt.Errorf("%w: %s", err, msg)
}

View File

@ -0,0 +1,54 @@
package apiserver
import (
"errors"
"net/http"
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/server"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
"git.maronato.dev/maronato/goshort/internal/storage/models"
"github.com/go-chi/render"
)
type APIHandler struct {
service *shortservice.ShortService
}
func NewAPIHandler(service *shortservice.ShortService) *APIHandler {
return &APIHandler{
service: service,
}
}
func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get the URL from the json body
var short *models.Short
if err := render.DecodeJSON(r.Body, &short); err != nil {
server.RenderRender(w, r, server.ErrBadRequest(err))
return
}
// Shorten URL
short, err := h.service.Shorten(ctx, short)
if err != nil {
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
server.RenderRender(w, r, server.ErrBadRequest(err))
return
} else {
server.RenderRender(w, r, server.ErrServerError(err))
return
}
}
// Render the response
render.Status(r, http.StatusCreated)
render.JSON(w, r, short)
}

View File

@ -0,0 +1,15 @@
package apiserver
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func NewAPIRouter(h *APIHandler) http.Handler {
mux := chi.NewRouter()
mux.Post("/short", h.CreateShort)
return mux
}

View File

@ -0,0 +1,129 @@
package devuiserver
import (
"context"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"syscall"
"git.maronato.dev/maronato/goshort/internal/config"
"golang.org/x/sync/errgroup"
)
type Server struct {
// apiUrl is API server's URL
apiURL string
// uiPort is the port the UI server will listen on
uiPort string
// host is the host the UI server will listen on
host string
// Cancel function to stop the server
cancel context.CancelFunc
}
// NewServer creates a new dev server.
func NewServer(cfg *config.Config) *Server {
apiURL := url.URL{
Host: net.JoinHostPort(cfg.Host, cfg.Port),
Scheme: "http",
Path: "/api",
}
return &Server{
apiURL: apiURL.String(),
uiPort: cfg.UIPort,
host: cfg.Host,
cancel: func() {},
}
}
func (s *Server) ListenAndServe(ctx context.Context) error {
eg, egCtx := errgroup.WithContext(ctx)
eg.Go(func() error {
return s.Start(egCtx)
})
eg.Go(func() error {
// Wait for the context to be done
<-egCtx.Done()
// Shutdown the server
return s.Shutdown()
})
if err := eg.Wait(); err != nil {
return fmt.Errorf("UI server exited with error: %w", err)
}
return nil
}
func (s *Server) Start(ctx context.Context) error {
// Create a new context with a cancel function so we can stop the server
uiCtx, cancel := context.WithCancel(ctx)
defer cancel()
s.cancel = cancel
// Build args for the UI server command
args := []string{"run", "--prefix", "frontend", "dev", "--", "--port", s.uiPort, "--host", s.host}
// Create the command
cmd := exec.Command("npm", args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
// Create a new process group so we can kill the process and all its children
Setpgid: true,
}
// Set the API_URL env var
cmd.Env = append(os.Environ(), "VITE_API_URL="+s.apiURL)
// Use the current process's stdout
cmd.Stdout = os.Stdout
// Create an errgroup to run the command and wait for the context to be done
eg, egCtx := errgroup.WithContext(uiCtx)
eg.Go(func() error {
// Execute the command
// Start the command execution
if err := cmd.Run(); err != nil {
return fmt.Errorf("error starting the server: %w", err)
}
return cmd.Wait() //nolint:wrapcheck // We'll wrap the error when eg.Wait returns
})
eg.Go(func() error {
// Wait for the context to be done
<-egCtx.Done()
// Get process pid and manually send a SIGKILL signal
// to the process group
pgid, err := syscall.Getpgid(cmd.Process.Pid)
if err != nil {
return fmt.Errorf("error getting process group id: %w", err)
}
err = syscall.Kill(-pgid, syscall.SIGKILL)
if err != nil {
return fmt.Errorf("error killing process group: %w", err)
}
return nil
})
// Wait for the context to be done and report erros that are not
// caused by the context being canceled
if err := eg.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("UI server exited with error: %w", err)
}
return nil
}
func (s *Server) Shutdown() error {
// Call the cancel function to stop the server
s.cancel()
return nil
}

67
internal/server/errors.go Normal file
View File

@ -0,0 +1,67 @@
package server
import (
"net/http"
"github.com/go-chi/render"
)
// ErrResponse renderer type for handling all sorts of errors.
//
// In the best case scenario, the excellent github.com/pkg/errors package
// helps reveal information on the error, setting it on Err, and in the Render()
// method, using it to set the application-specific error code in AppCode.
type ErrResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
AppCode int64 `json:"code,omitempty"` // application-specific error code
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}
func ErrGeneric(err error, status int) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: status,
StatusText: http.StatusText(status),
ErrorText: err.Error(),
}
}
func ErrBadRequest(err error) render.Renderer {
return ErrGeneric(err, http.StatusBadRequest)
}
func ErrServerError(err error) render.Renderer {
return ErrGeneric(err, http.StatusInternalServerError)
}
func ErrNotFound(err error) render.Renderer {
return ErrGeneric(err, http.StatusNotFound)
}
func ErrRendering(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: 422,
StatusText: "Error rendering response.",
ErrorText: err.Error(),
}
}
func RenderRender(w http.ResponseWriter, r *http.Request, resp render.Renderer) {
// Try to render the response
if err := render.Render(w, r, resp); err != nil {
// If error, try to render that an error ocurred
if err := render.Render(w, r, ErrRendering(err)); err != nil {
// If error, panic
panic(err)
}
}
}

View File

@ -0,0 +1,36 @@
package healthcheckserver
import (
"net/http"
"github.com/go-chi/render"
)
type HealthcheckHandler struct{}
func NewHealthcheckHandler() *HealthcheckHandler {
return &HealthcheckHandler{}
}
func (h *HealthcheckHandler) CheckHealth(w http.ResponseWriter, r *http.Request) {
// Create the response
response := &healthCheckResponse{
Status: "ok",
ServerOK: true,
DatabaseOK: true,
}
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, response)
}
type healthCheckResponse struct {
// Status is the status of the health check
Status string `json:"status"`
// ServerOK is the status of the server
ServerOK bool `json:"server_ok"`
// DatabaseOK is the status of the database
DatabaseOK bool `json:"database_ok"`
}

View File

@ -0,0 +1,15 @@
package healthcheckserver
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func NewHealthcheckRouter(h *HealthcheckHandler) http.Handler {
mux := chi.NewRouter()
mux.Get("/", h.CheckHealth)
return mux
}

75
internal/server/server.go Normal file
View File

@ -0,0 +1,75 @@
package server
import (
"context"
"fmt"
"net"
"net/http"
"git.maronato.dev/maronato/goshort/internal/config"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"golang.org/x/sync/errgroup"
)
type Server struct {
srv *http.Server
Mux *chi.Mux
}
func NewServer(cfg *config.Config) *Server {
// Parse the address
addr := net.JoinHostPort(cfg.Host, cfg.Port)
// Create the mux
mux := chi.NewRouter()
// Register default middlewares
mux.Use(middleware.RequestID)
mux.Use(middleware.RealIP)
mux.Use(middleware.Logger)
mux.Use(middleware.Recoverer)
mux.Use(middleware.Timeout(config.RequestTimeout))
// Create the server
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: config.ReadHeaderTimeout,
ReadTimeout: config.ReadTimeout,
WriteTimeout: config.WriteTimeout,
IdleTimeout: config.IdleTimeout,
}
return &Server{
srv: srv,
Mux: mux,
}
}
func (s *Server) ListenAndServe(ctx context.Context) error {
// Create the errorgroup that will manage the server execution
eg, egCtx := errgroup.WithContext(ctx)
// Start the server
eg.Go(func() error {
return s.srv.ListenAndServe()
})
// Gracefully shutdown the server when the context is done
eg.Go(func() error {
// Wait for the context to be done
<-egCtx.Done()
return s.srv.Shutdown( //nolint:contextcheck // Background allows the server to shutdown gracefully
context.Background(),
)
})
// Ignore the error if the context was canceled
if err := eg.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("server exited with error: %w", err)
}
return nil
}

View File

@ -0,0 +1,49 @@
package shortserver
import (
"errors"
"fmt"
"net/http"
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/server"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
"github.com/go-chi/chi/v5"
)
type ShortHandler struct {
service *shortservice.ShortService
}
func NewShortHandler(service *shortservice.ShortService) *ShortHandler {
return &ShortHandler{
service: service,
}
}
func (h *ShortHandler) FindShort(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get the short URL from the request
name := chi.URLParam(r, "short")
// Get the URL from the service
short, err := h.service.FindShort(ctx, name)
switch {
case err == nil:
// If there's no error, redirect to the URL
http.Redirect(w, r, short.URL, http.StatusSeeOther)
case errors.Is(err, errs.ErrInvalidShort):
// If the short name is invalid, do nothing and let the static handler
// take care of it.
case errors.Is(err, errs.ErrShortDoesNotExist):
// If the short doesn't exist, do nothing and let the static handler
// take care of it.
fmt.Println("Short doesn't exist")
default:
// Oops, this shouldn't happen.
server.RenderRender(w, r, server.ErrServerError(err))
}
}

View File

@ -0,0 +1,22 @@
package shortserver
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func NewShortRouter(h *ShortHandler) http.Handler {
mux := chi.NewRouter()
// Match the short url
mux.Get("/{short}", h.FindShort)
// Match everything else with an empty handler,
// so chi doesn't respond with a 404 by default.
// We do this so that the next handler, the static handler,
// can receive non-matching requests and handle them.
mux.Get("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
return mux
}

View File

@ -0,0 +1,32 @@
package staticssterver
import (
"net/http"
"git.maronato.dev/maronato/goshort/frontend"
"git.maronato.dev/maronato/goshort/internal/config"
)
type StaticHandler struct {
assetServer http.Handler
}
func NewStaticHandler(cfg *config.Config) *StaticHandler {
// Get the assets from the frontend
assets, _ := frontend.Assets()
// Create the root filesystem
root := http.FS(assets)
// Build the asset server
assetServer := http.StripPrefix("/", http.FileServer(root))
return &StaticHandler{
assetServer: assetServer,
}
}
func (h *StaticHandler) ServeFiles(w http.ResponseWriter, r *http.Request) {
// Serve the files
h.assetServer.ServeHTTP(w, r)
}

View File

@ -0,0 +1,15 @@
package staticssterver
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func NewStaticRouter(h *StaticHandler) http.Handler {
mux := chi.NewRouter()
mux.Get("/*", h.ServeFiles)
return mux
}

View File

@ -0,0 +1,139 @@
package shortservice
import (
"context"
"fmt"
"net/url"
"regexp"
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/storage"
"git.maronato.dev/maronato/goshort/internal/storage/models"
shortutil "git.maronato.dev/maronato/goshort/internal/util/short"
)
const (
// DefaultShortLength is the default length of the short URL.
DefaultShortLength = 8
// MinShortLength is the minimum length of the short URL.
MinShortLength = 4
// MaxShortLength is the maximum length of the short URL.
MaxShortLength = 32
)
type ShortService struct {
db storage.Storage
}
func NewShortService(db storage.Storage) *ShortService {
return &ShortService{db: db}
}
func (s *ShortService) FindShort(ctx context.Context, name string) (*models.Short, error) {
// Check if the short is valid
err := ShortNameIsValid(name)
if err != nil {
return &models.Short{}, fmt.Errorf("could not validate short: %w", err)
}
// Get the URL from storage
short, err := s.db.FindShort(ctx, name)
if err != nil {
return short, fmt.Errorf("could not get URL from storage: %w", err)
}
return short, nil
}
// ShortenNamedURL shortens a URL with a given short name.
func (s *ShortService) shortenNamedURL(ctx context.Context, name, url string) (*models.Short, error) {
// Allocate new short
short := &models.Short{
Name: name,
URL: url,
}
// make sure the short is valid
err := ShortIsValid(short)
if err != nil {
return &models.Short{}, fmt.Errorf("could not validate short: %w", err)
}
// Set the URL in storage
err = s.db.CreateShort(ctx, short)
if err != nil {
return &models.Short{}, fmt.Errorf("could not set URL in storage: %w", err)
}
return short, nil
}
// ShortenURL shortens a URL with a random short name.
func (s *ShortService) shortenURL(ctx context.Context, url string) (*models.Short, error) {
// Generate a random short URL
name := shortutil.GenerateRandomShort(DefaultShortLength)
// Do the shortening
return s.shortenNamedURL(ctx, name, url)
}
func (s *ShortService) Shorten(ctx context.Context, short *models.Short) (*models.Short, error) {
// Check if the short is empty
if short.Name == "" {
// Shorten the URL with a random short name
return s.shortenURL(ctx, short.URL)
}
// Shorten the URL with the given short name
return s.shortenNamedURL(ctx, short.Name, short.URL)
}
var shortPattern = fmt.Sprintf("[a-zA-Z0-9_-]{%d,%d}", MinShortLength, MaxShortLength)
var shortRegex = regexp.MustCompile(fmt.Sprintf("^%s$", shortPattern))
func ShortNameIsValid(name string) error {
if !shortRegex.MatchString(name) {
return errs.Error(
errs.ErrInvalidShort,
fmt.Sprintf(
"short must use only letters, numbers, underscores and dashes, and be between %d and %d characters long",
MinShortLength,
MaxShortLength,
),
)
}
return nil
}
func ShortURLIsValid(shortURL string) error {
parsedURL, err := url.ParseRequestURI(shortURL)
if err != nil {
return errs.Error(errs.ErrInvalidShort, "invalid URL")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return errs.Error(errs.ErrInvalidShort, "invalid URL scheme")
}
return nil
}
// ShortIsValid checks if the short is valid.
func ShortIsValid(short *models.Short) error {
// Check if the short name is valid
err := ShortNameIsValid(short.Name)
if err != nil {
return err
}
// Check if the URL is valid
err = ShortURLIsValid(short.URL)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,56 @@
package memorystorage
import (
"context"
"sync"
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/storage"
"git.maronato.dev/maronato/goshort/internal/storage/models"
)
// MemoryStorage is a storage that stores everything in memory.
type MemoryStorage struct {
storage.Storage
mu sync.RWMutex
shortMap map[string]string
}
// NewMemoryStorage creates a new MemoryStorage.
func NewMemoryStorage() *MemoryStorage {
return &MemoryStorage{
shortMap: make(map[string]string),
}
}
// FindShort finds a short in the storage.
func (s *MemoryStorage) FindShort(ctx context.Context, name string) (*models.Short, error) {
s.mu.RLock()
defer s.mu.RUnlock()
short := &models.Short{}
url, ok := s.shortMap[name]
if !ok {
return short, errs.ErrShortDoesNotExist
}
short.Name = name
short.URL = url
return short, nil
}
func (s *MemoryStorage) CreateShort(ctx context.Context, short *models.Short) error {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.shortMap[short.Name]
if ok {
return errs.ErrShortExists
}
s.shortMap[short.Name] = short.URL
return nil
}

View File

@ -0,0 +1,6 @@
package models
type Short struct {
Name string `json:"name,omitempty"`
URL string `json:"url"`
}

View File

@ -0,0 +1,12 @@
package storage
import (
"context"
"git.maronato.dev/maronato/goshort/internal/storage/models"
)
type Storage interface {
FindShort(ctx context.Context, name string) (*models.Short, error)
CreateShort(ctx context.Context, short *models.Short) error
}

View File

@ -0,0 +1,34 @@
package handlerutils
import (
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
// chainHandlers chains multiple handlers together, and returns a handler
// that will call each handler in order, until one of them writes a response.
func chainHandlers(handlers ...http.Handler) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Create a new CheckWrittenWriter
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
// For each handler, call it and check if the response was written
for _, handler := range handlers {
handler.ServeHTTP(ww, r)
// If the response was written, stop the chain
if ww.Status() != 0 {
return
}
}
}
}
// NewChainedHandler returns a new handler that will call each handler in order,
// until one of them writes a response.
func NewChainedHandler(handlers ...http.Handler) http.Handler {
return http.HandlerFunc(
chainHandlers(handlers...),
)
}

View File

@ -0,0 +1,3 @@
package shortutil
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" // 64 possibilities

View File

@ -0,0 +1,35 @@
// https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go
package shortutil
import (
"math/rand"
"strings"
"time"
)
const (
letterIdxBits = 7 // 7 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
var src = rand.NewSource(time.Now().UnixNano())
func GenerateRandomShort(n int) string {
sb := strings.Builder{}
sb.Grow(n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(alphabet) {
sb.WriteByte(alphabet[idx])
i--
}
cache >>= letterIdxBits
remain--
}
return sb.String()
}

BIN
results.bin Normal file

Binary file not shown.