Frederic G. MARAND 1 year ago
commit
ba165d9eb5

+ 1 - 0
.env

@@ -0,0 +1 @@
+FIZZBUZZ_ADDR=:6060

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+/.idea
+/fizzbuzz
+/lbc
+/logs/*

+ 11 - 0
.run/Run bare.run.xml

@@ -0,0 +1,11 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="Run bare" type="GoApplicationRunConfiguration" factoryName="Go Application">
+    <module name="lbc" />
+    <working_directory value="$PROJECT_DIR$" />
+    <kind value="PACKAGE" />
+    <package value="code.osinet.fr/fgm/lbc" />
+    <directory value="$PROJECT_DIR$" />
+    <filePath value="$PROJECT_DIR$/main.go" />
+    <method v="2" />
+  </configuration>
+</component>

+ 12 - 0
.run/Run configure.run.xml

@@ -0,0 +1,12 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="Run configure" type="GoApplicationRunConfiguration" factoryName="Go Application">
+    <module name="lbc" />
+    <working_directory value="$PROJECT_DIR$" />
+    <parameters value="-v configure" />
+    <kind value="PACKAGE" />
+    <package value="code.osinet.fr/fgm/lbc/cmd" />
+    <directory value="$PROJECT_DIR$" />
+    <filePath value="$PROJECT_DIR$/cmd/serve.go" />
+    <method v="2" />
+  </configuration>
+</component>

+ 15 - 0
.run/Run serve.run.xml

@@ -0,0 +1,15 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="Run serve" type="GoApplicationRunConfiguration" factoryName="Go Application">
+    <module name="lbc" />
+    <working_directory value="$PROJECT_DIR$" />
+    <parameters value="-v -base=fb -wt=2m" />
+    <envs>
+      <env name="FIZZBUZZ_ADDR" value=":6060" />
+    </envs>
+    <kind value="PACKAGE" />
+    <package value="code.osinet.fr/fgm/lbc/cmd/fizzbuzz" />
+    <directory value="$PROJECT_DIR$" />
+    <filePath value="$PROJECT_DIR$/main.go" />
+    <method v="2" />
+  </configuration>
+</component>

+ 8 - 0
.run/Test.run.xml

@@ -0,0 +1,8 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="Test" type="MAKEFILE_TARGET_RUN_CONFIGURATION" factoryName="Makefile">
+    <makefile filename="$PROJECT_DIR$/Makefile" target="test" workingDirectory="" arguments="">
+      <envs />
+    </makefile>
+    <method v="2" />
+  </configuration>
+</component>

+ 1 - 0
LICENSE

@@ -0,0 +1 @@
+(c) 2022 Frédéric G. MARAND <fgm@osinet.fr> - All rights reserved.

+ 53 - 0
Makefile

@@ -0,0 +1,53 @@
+LOGS=logs
+COVER=$(LOGS)/cover.out
+COMMAND=fizzbuzz
+# Assumes the most common case of a single-component GOPATH.
+BINDIR := $(shell go env GOPATH)/bin
+
+.PHONY: all
+all: clean deps build
+
+.PHONY: build
+build: lint test
+	go get ./...
+	go mod tidy
+	go build ./cmd/$(COMMAND)
+
+.PHONY: clean
+clean:
+	# Adding -modcache is counter-productive in most cases as it is global
+	go clean -i -r -x -cache -fuzzcache -testcache | cut -c1-150
+	rm -fr logs/*
+	rm -f $(COMMAND) $(BINDIR)/$(COMMAND)
+
+.PHONY: cover
+cover: deps
+	go test -covermode=atomic -coverprofile=$(COVER) ./...
+	go tool cover -html=$(COVER)
+	gocoverstats -f $(COVER) -v -percent
+
+.PHONY: deps
+deps:
+	go install gitlab.com/fgmarand/gocoverstats@latest
+	go install github.com/fgm/envrun@latest
+	go install honnef.co/go/tools/cmd/staticcheck@latest
+
+.PHONY: fuzz
+fuzz:
+	go test -fuzz=FuzzFizzBuzz -v -fuzztime=10s ./domain
+
+.PHONY: install
+install: deps build
+	go install ./cmd/$(COMMAND)
+
+.PHONY: lint
+lint:
+	staticcheck -checks=all ./...
+
+.PHONY: serve
+serve: install
+	envrun -f .env $(COMMAND) -v -base=fb
+
+.PHONY: test
+test: lint
+	go test -race ./...

+ 88 - 0
README.md

@@ -0,0 +1,88 @@
+
+# Description
+
+"The original fizz-buzz consists in writing all numbers from 1 to 100,
+and just replacing all multiples of 3 by ""fizz"", all multiples of 5
+by ""buzz"", and all multiples of 15 by ""fizzbuzz"".
+The output would look like this:
+""1,2,fizz,4,buzz,fizz,7,8,fizz,buzz,11,fizz,13,14,fizzbuzz,16,...""."
+
+Your goal is to implement a web server that will expose a REST API
+endpoint that:
+
+- Accepts five parameters: three integers int1, int2 and limit, and
+  two strings str1 and str2.
+- Returns a list of strings with numbers from 1 to limit, where: all
+  multiples of int1 are replaced by str1, all multiples of int2 are
+  replaced by str2, all multiples of int1 and int2 are replaced by
+  str1str2.
+
+The server needs to be:
+
+- Ready for production
+- Easy to maintain by other developers
+
+Bonus: add a statistics endpoint allowing users to know what the most
+frequent request has been. This endpoint should:
+
+- Accept no parameter
+- Return the parameters corresponding to the most used request, as
+  well as the number of hits for this request
+
+
+# Deliverables
+
+## Clarified specifications
+
+- `int1` and `int2` must be different and within the open interval `]1, limit[`.
+- non-specific integers are displayed as their unaligned base 10 representation
+- `str1` and `str2` must be valid path components as per [RFC 3986 §2.2].
+- the fizzbuzz handler, as specified, is not REST, as it:
+  - does not expose a representation of a resource,
+  - only handles GET operations,
+  - does not return links to provide navigation, so does not implement HATEOAS
+
+[RFC 3986 §2.2]: https://www.rfc-editor.org/rfc/rfc3986#section-2.2
+
+
+## Features
+
+- 100% S0 coverage on domain
+- features:
+  - stats implemented as a JSON result
+  - listen addr is overridable:
+    - default: `:8080`
+    - environment variable `FIZZBUZZ_ADDR` overrides default
+    - flag `-addr` overrides environment variable
+ 
+Note: environment can be loaded from `.env` file: see `make serve`. 
+
+
+## Checklist
+
+- [X] Features coverage
+  - [X] FizzBuzz++ handler
+- [X] Bonus features coverage
+  - [X] Stats handler
+- [X] Test coverage
+  - [X] Unit tests high on domain
+  - [X] Fuzz tests on computations
+  - [X] Integration tests on web handlers
+- [X] [12-factor] app
+  - [X] VCS: Git
+  - [X] Dependencies: `go.mod`
+  - [X] Config in the environment ([envflag])
+  - [X] Backing services via URLs: none, not applicable
+  - [X] Build, release, run: build with standard tools
+  - [X] Stateless processes: handler is a pure function
+  - [X] Port binding: `-addr` flag, `FIZZBUZZ_ADDR` env var
+  - [X] Concurrency: processes supported by default
+  - [X] Disposability:
+    - clean server shutdown on `SIGTERM`
+    - no state to save/restore
+  - [X] Dev/prod parity: irrelevant in the context
+  - [X] Logs to `stdout` (not `stderr`)
+  - [X] Admin process: not applicable, nothing to administer
+
+[12-factor]: https://12factor.net/
+[envrun]: https://github.com/dcowgill/envflag

+ 96 - 0
cmd/fizzbuzz/di.go

@@ -0,0 +1,96 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"time"
+
+	"code.osinet.fr/fgm/izidic"
+	"github.com/dcowgill/envflag"
+
+	"code.osinet.fr/fgm/lbc/internal"
+	"code.osinet.fr/fgm/lbc/web"
+)
+
+// 12-factor/XI: log to stdout.
+func loggerService(dic *izidic.Container) (any, error) {
+	sw, err := dic.Param("writer")
+	if err != nil {
+		return nil, err
+	}
+	w, ok := sw.(io.Writer)
+	if !ok {
+		return nil, fmt.Errorf("incorrect type for service writer: %T", sw)
+	}
+	logger := log.New(w, "", log.LstdFlags)
+	// Support code not able to use an injected logger
+	log.SetOutput(w)
+	return logger, nil
+}
+
+func configService(dic *izidic.Container) (any, error) {
+	name := dic.MustParam("name").(string)
+	// Provide fallback configuration.
+	c := &web.Config{
+		Config: internal.Config{
+			Verbose: false,
+		},
+
+		Addr:         ":8080",
+		Base:         "/",
+		WriteTimeout: 100 * time.Millisecond,
+	}
+
+	// Build runtime configuration.
+	fs := flag.NewFlagSet(name, flag.ContinueOnError)
+	fs.StringVar(&c.Addr, "addr", c.Addr, "the address on which to listen")
+	fs.StringVar(&c.Base, "base", c.Base, "the base path for the server")
+	fs.BoolVar(&c.Verbose, "v", c.Verbose, "be verbose")
+	fs.DurationVar(&c.WriteTimeout, "wt", c.WriteTimeout, "the write timeout")
+	err := fs.Parse(dic.MustParam("args").([]string))
+	if err != nil {
+		return nil, err
+	}
+	vs := envflag.NewVarSet(fs)
+	vs.SetPrefix(name)
+	vs.Parse()
+
+	return c, nil
+}
+
+func statsService(dic *izidic.Container) (any, error) {
+	return web.NewStats(), nil
+}
+
+func webService(dic *izidic.Container) (any, error) {
+	logger := dic.MustService("logger").(*log.Logger)
+	config := dic.MustService("config").(*web.Config)
+	stats := dic.MustService("stats").(*web.Stats)
+
+	ctx := context.Background()
+	return &web.Server{
+		BaseContext: ctx,
+		Config:      config,
+		Logger:      logger,
+		Stats:       stats,
+	}, nil
+}
+
+// resolve configures dependency injection.
+func resolve(w io.Writer, args []string) *izidic.Container {
+	dic := izidic.New()
+	dic.Register("config", configService)
+	dic.Register("logger", loggerService)
+	dic.Register("stats", statsService)
+	dic.Register("web", webService)
+
+	dic.Store("name", "fizzbuzz")
+	dic.Store("args", args[1:])
+	dic.Store("writer", w)
+
+	dic.Freeze()
+	return dic
+}

+ 26 - 0
cmd/fizzbuzz/main.go

@@ -0,0 +1,26 @@
+package main
+
+import (
+	"log"
+	"os"
+
+	"code.osinet.fr/fgm/lbc/domain"
+	"code.osinet.fr/fgm/lbc/web"
+)
+
+func main() {
+	dic := resolve(os.Stdout, os.Args)
+
+	logger := dic.MustService("logger").(*log.Logger)
+	s, err := dic.Service("web")
+	if err != nil {
+		logger.Fatal(err)
+	}
+	server, ok := s.(*web.Server)
+	if !ok {
+		logger.Fatalf("incorrect type for web server: %T", s)
+	}
+	if err := server.Serve(make(chan domain.Empty)); err != nil {
+		logger.Fatal(err)
+	}
+}

+ 119 - 0
domain/fizzbuzz.go

@@ -0,0 +1,119 @@
+// Package domain provides the domain logic for the extended FizzBuzz computation.
+package domain
+
+import (
+	"errors"
+	"fmt"
+	"math"
+	"regexp"
+	"strconv"
+)
+
+type (
+	Empty     struct{}
+	modulo    int64
+	moduloMap map[modulo]struct{}
+)
+
+const (
+	Fizz = "fizz"
+	Buzz = "buzz"
+	Both = Fizz + Buzz
+)
+
+var (
+	// RFC3986GenDelims matches the reserved characters in RFC3986 §2.2
+	//
+	// See https://www.rfc-editor.org/rfc/rfc3986#section-2.2
+	RFC3986GenDelims = regexp.MustCompile(`[:|/\?#\[\]@]`)
+	Void             Empty
+)
+
+// IsValid returns true if the received is in the open interval ]1,limit[,
+// and is not already present in the existing map.
+func (m modulo) IsValid(limit modulo, existing moduloMap) bool {
+	if m <= 1 || m > limit {
+		return false
+	}
+	if _, found := existing[m]; found {
+		return false
+	}
+	return true
+}
+
+// FizzBuzzes is an ordered list of FizzBuzz strings;
+type FizzBuzzes []string
+
+// validateNumbers fails if either of the numbers fails validation.
+//
+// A successful return means that limit is >= 1 and int1 and int2 are different and within [1,limit[.
+func validateNumbers(int1, int2, limit int) error {
+	existing := make(moduloMap, 2)
+	l := modulo(limit)
+	if !l.IsValid(math.MaxInt, existing) {
+		return errors.New("limit is invalid")
+	}
+	m1 := modulo(int1)
+	if !m1.IsValid(l, existing) {
+		return errors.New("int1 is invalid")
+	}
+	existing[m1] = Void
+	m2 := modulo(int2)
+	if !m2.IsValid(l, existing) {
+		return errors.New("int2 is invalid")
+	}
+	return nil
+}
+
+func validateStrings(str1, str2 string) error {
+	validateString := func(s string) error {
+		if len(s) == 0 {
+			return fmt.Errorf("invalid string parameter: empty")
+		}
+		if RFC3986GenDelims.MatchString(s) {
+			return fmt.Errorf("contains a RFC-3986 reserved character")
+		}
+		return nil
+	}
+	ss := [...]string{str1, str2}
+	for i, s := range ss {
+		if err := validateString(s); err != nil {
+			return fmt.Errorf("validateString(%d): %w", i, err)
+		}
+	}
+	return nil
+}
+
+// FizzBuzz builds a list of strings with numbers in the closed interval [1,limit], where:
+//
+//   - all multiples of int1 are replaced by str1, except those that are also multiples of int2
+//   - all multiples of int2 are replaced by str2, except those that are also multiples of int1
+//   - all multiples of int1 and int2 are replaced by str1str2.
+func FizzBuzz(int1, int2, limit int, str1, str2 string) (FizzBuzzes, error) {
+	if err := validateNumbers(int1, int2, limit); err != nil {
+		return nil, fmt.Errorf("invalid modulo parameters: %w", err)
+	}
+	if err := validateStrings(str1, str2); err != nil {
+		return nil, fmt.Errorf("invalid string parameters: %w", err)
+	}
+	both := str1 + str2
+
+	fbs := make(FizzBuzzes, 0, limit)
+
+	// Arithmetic for loop on append to a preallocated slice is usually faster than
+	// indexed access as in fbs := make(FizzBuzzes, limit); loop on fbs[i] = <value>.
+	for i := 1; i <= limit; i++ {
+		// The switch is more readable than the usual nested "if" solution.
+		switch {
+		case i%int1 == 0 && i%int2 == 0:
+			fbs = append(fbs, both)
+		case i%int1 == 0:
+			fbs = append(fbs, str1)
+		case i%int2 == 0:
+			fbs = append(fbs, str2)
+		default:
+			fbs = append(fbs, strconv.Itoa(i))
+		}
+	}
+	return fbs, nil
+}

+ 28 - 0
domain/fizzbuzz_fuzz_test.go

@@ -0,0 +1,28 @@
+package domain
+
+import (
+	"regexp"
+	"strings"
+	"testing"
+)
+
+func FuzzFizzBuzz(f *testing.F) {
+	rx := regexp.MustCompile(strings.Join([]string{
+		"invalid modulo parameters: ",
+		"invalid string parameter: empty",
+		"contains a RFC-3986 reserved character",
+	}, "|"))
+	target := func(t *testing.T, int1, int2, limit int, str1, str2 string) {
+		fbs, err := FizzBuzz(int1, int2, limit, str1, str2)
+		if err != nil && !rx.MatchString(err.Error()) {
+			t.Fatalf("Unexpected error: %s", err)
+		}
+		if fbs == nil && err == nil {
+			t.Fatalf("unexpected nil result")
+		}
+		if len(fbs) == 0 && err == nil {
+			t.Fatal("unexpected empty result")
+		}
+	}
+	f.Fuzz(target)
+}

+ 48 - 0
domain/fizzbuzz_opaque_test.go

@@ -0,0 +1,48 @@
+package domain_test
+
+import (
+	"reflect"
+	"testing"
+
+	"code.osinet.fr/fgm/lbc/domain"
+)
+
+func TestFizzbuzz(t *testing.T) {
+	t.Parallel()
+
+	const Fizz, Buzz, Both = domain.Fizz, domain.Buzz, domain.Both
+	type args struct {
+		int1  int
+		int2  int
+		limit int
+		str1  string
+		str2  string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    domain.FizzBuzzes
+		wantErr bool
+	}{
+		{"happy", args{2, 5, 11, Fizz, Buzz}, domain.FizzBuzzes{"1", Fizz, "3", Fizz, Buzz, Fizz, "7", Fizz, "9", Both, "11"}, false},
+		{"sad modulo 1", args{1, 5, 10, Fizz, Buzz}, nil, true},
+		{"sad modulo reused", args{5, 5, 10, Fizz, Buzz}, nil, true},
+		{"sad modulo limit", args{2, 5, 0, Fizz, Buzz}, nil, true},
+		{"sad empty string", args{2, 5, 11, Fizz, ""}, nil, true},
+		{"sad reserved string", args{2, 5, 11, Fizz, "/"}, nil, true},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got, err := domain.FizzBuzz(tt.args.int1, tt.args.int2, tt.args.limit, tt.args.str1, tt.args.str2)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("FizzBuzz() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("FizzBuzz() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 33 - 0
domain/fizzbuzz_transparent_test.go

@@ -0,0 +1,33 @@
+package domain
+
+import "testing"
+
+func Test_validate(t *testing.T) {
+	t.Parallel()
+
+	type args struct {
+		int1  int
+		int2  int
+		limit int
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{"happy", args{2, 5, 10}, false},
+		{"sad: 1 out of range", args{1, 5, 10}, true},
+		{"sad: 2 out of range", args{2, 11, 10}, true},
+		{"sad: limit out of range", args{2, 5, 1}, true},
+		{"sad: repeated", args{2, 2, 10}, true},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			if err := validateNumbers(tt.args.int1, tt.args.int2, tt.args.limit); (err != nil) != tt.wantErr {
+				t.Errorf("validateNumbers() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}

+ 10 - 0
go.mod

@@ -0,0 +1,10 @@
+module code.osinet.fr/fgm/lbc
+
+go 1.19
+
+require (
+	code.osinet.fr/fgm/izidic v0.0.0-20220911104954-429c959277cf
+	github.com/dcowgill/envflag v1.0.0
+	github.com/gorilla/mux v1.8.0
+	golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
+)

+ 8 - 0
go.sum

@@ -0,0 +1,8 @@
+code.osinet.fr/fgm/izidic v0.0.0-20220911104954-429c959277cf h1:m91RxMCBQornPnMO6waXZc/CnzmEnfx8gL/TdfZpLns=
+code.osinet.fr/fgm/izidic v0.0.0-20220911104954-429c959277cf/go.mod h1:bSYUpRib9pCI1fWxUoMFgCocFwHmyxZXpc+ee6vTpV0=
+github.com/dcowgill/envflag v1.0.0 h1:pTfbp0MhS1suIidoXxQOtGQx+dH2XTkMWkzS61vwkcg=
+github.com/dcowgill/envflag v1.0.0/go.mod h1:ORkAAPrdrhTT/sIIkzTJF3jiyw+2PnXLwSTC6eIuUsg=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=

+ 8 - 0
internal/context.go

@@ -0,0 +1,8 @@
+// Package internal contains data internal to the application,
+// available to its services, but not to any code importing them.
+package internal
+
+// Config is the global union config for the application, regardless of the command used
+type Config struct {
+	Verbose bool
+}

+ 202 - 0
web/serve.go

@@ -0,0 +1,202 @@
+// Package web contains the web-related features: handlers and middleware
+package web
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"os/signal"
+	"regexp"
+	"strconv"
+	"time"
+
+	"github.com/gorilla/mux"
+
+	"code.osinet.fr/fgm/lbc/domain"
+	"code.osinet.fr/fgm/lbc/internal"
+)
+
+type (
+	ConfigKeyType string
+	Config        struct {
+		internal.Config
+		Addr         string
+		Base         string // Mount point for service
+		WriteTimeout time.Duration
+	}
+	Server struct {
+		BaseContext context.Context
+
+		*Config
+		*Stats
+		*log.Logger
+	}
+)
+
+func (c Config) String() string {
+	return fmt.Sprintf("Addr: %s, Base URL: %s, Write Timeout: %v",
+		c.Addr, c.Base, c.WriteTimeout)
+}
+
+const (
+	ConfigKey ConfigKeyType = "config"
+
+	// CT is the normalized representation of the content-type header.
+	CT = "Content-Type"
+
+	// JSON is the normalized representation of the JSON content-type per RFC4627 §6.
+	JSON = "application/json; charset=utf-8"
+
+	PathFB = "/{int1:[0-9]+}/{int2:[0-9]+}/{limit:[0-9]+}/{str1}/{str2}"
+)
+
+var (
+	utf8       = regexp.MustCompile("utf-8")
+	acceptJSON = regexp.MustCompile(`(application/json|\*/\*)`)
+)
+
+func argsFromRequest(r *http.Request) (int1, int2, limit int, str1, str2 string, status int) {
+	var err error
+	v := mux.Vars(r)
+	status = http.StatusAccepted
+	for k, p := range map[string]*int{"int1": &int1, "int2": &int2, "limit": &limit} {
+		sn := v[k] // Cannot fail: route wouldn't match
+		*p, err = strconv.Atoi(sn)
+		if err != nil || *p < 2 {
+			return 0, 0, 0, "", "", http.StatusNotFound
+		}
+	}
+	for k, p := range map[string]*string{"str1": &str1, "str2": &str2} {
+		// Cannot fail: route wouldn't match
+		*p = v[k]
+	}
+	return
+}
+
+func isJSONAccepted(r *http.Request) bool {
+	// Only return to clients accepting UTF-8 or not specifying a charset
+	charset := r.Header.Get("Accept-Charset")
+	if charset != "" && !utf8.MatchString(charset) {
+		return false
+	}
+	// Only return to clients accepting JSON, anything, or not specifying Accept.
+	accept := r.Header.Get("Accept")
+	return accept == "" || acceptJSON.MatchString(accept)
+}
+
+func MakeFizzBuzz(logger *log.Logger, isVerbose bool) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		// Can we serve client at all?
+		if !isJSONAccepted(r) {
+			if isVerbose {
+				logger.Println("request from client unable to accept JSON")
+			}
+			http.Error(w, "cannot offer an accepted content type: can only serve UTF-8 JSON", http.StatusNotAcceptable)
+			return
+		}
+
+		int1, int2, limit, str1, str2, status := argsFromRequest(r)
+		if status >= http.StatusBadRequest {
+			logger.Printf("Invalid URL requested: %s", r.URL) // Dev-targeted actual error
+			http.NotFound(w, r)                               // No human-targeted details to limit enumeration attacks
+			return
+		}
+
+		if isVerbose {
+			logger.Printf("request: %s", r.URL.Path)
+		}
+		fbs, err := domain.FizzBuzz(int1, int2, limit, str1, str2)
+		if err != nil {
+			logger.Println(err)                                                                            // Dev-targeted actual error
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // Human-targeted error to user agent
+			return
+		}
+		w.Header().Set(CT, JSON)
+		enc := json.NewEncoder(w)
+		enc.SetEscapeHTML(true) // Prevent XSS on str1/str2
+		enc.SetIndent("", "\t") // Human-readable JSON
+		err = enc.Encode(fbs)
+		if err != nil {
+			logger.Println(err)                                                                            // Dev-targeted actual error
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // human-targeted error to user agent
+			return
+		}
+	}
+}
+
+// SetupRouting configures the server route on the specified base path.
+func SetupRouting(base string, logger *log.Logger, stats *Stats, isVerbose bool) (http.Handler, error) {
+	restBase, err := url.JoinPath("/", base) // Perform base validation, including fixing "" as "/".
+	if err != nil {
+		return nil, fmt.Errorf("failed building the base path: %w", err)
+	}
+	sm := http.NewServeMux()
+	router := mux.NewRouter()
+	sub := router.PathPrefix(restBase).
+		Name("base").
+		Subrouter()
+	sub.Handle(PathFB, stats.Middleware()(MakeFizzBuzz(logger, isVerbose))).
+		Name("fizzbuzz")
+	sub.Handle("/stats", stats.Handler())
+	sm.Handle("/", sub)
+	return sm, nil
+}
+
+// Serve starts the HTTP listening loop.
+func (s *Server) Serve(serverClosed chan domain.Empty) error {
+	// Provide access to the globally injected services in handlers.
+	baseContext := func(listener net.Listener) context.Context {
+		return s.BaseContext
+	}
+	r, err := SetupRouting(s.Base, s.Logger, s.Stats, s.Verbose)
+	if err != nil {
+		s.Logger.Fatalf("failed setting up routing: %v", err)
+	}
+	hs := http.Server{
+		Addr:              s.Addr,
+		BaseContext:       baseContext,
+		ErrorLog:          s.Logger,
+		Handler:           r,
+		IdleTimeout:       100 * time.Millisecond, // Non-zero to improve benchmarks able to reuse connections.
+		MaxHeaderBytes:    8192,                   // Cf. Apache as industry-standard practice.
+		ReadHeaderTimeout: 10 * time.Millisecond,  // 8kB@1Mbps = 8 msec
+		ReadTimeout:       10 * time.Millisecond,  // This server has no method expecting a body, so keep this small
+		TLSConfig:         nil,
+		WriteTimeout:      s.WriteTimeout, // 1Mbps*100msec = 100kB, which should be sufficient for this API
+	}
+
+	// Setup clean shutdown on signal.
+	go func() {
+		sigs := make(chan os.Signal, 1)
+		signal.Notify(sigs, os.Interrupt)
+		<-sigs
+		if err := hs.Shutdown(context.Background()); err != nil {
+			s.Logger.Printf("web server shutdown: %v", err)
+		}
+		close(serverClosed)
+	}()
+
+	if s.Verbose {
+		s.Logger.Printf("listening with config %s", s.Config)
+	}
+
+	go func() {
+		err = hs.ListenAndServe()
+		switch err {
+		case http.ErrServerClosed:
+			if s.Verbose {
+				s.Logger.Printf("http server shut down cleanly")
+			}
+			err = nil
+		default:
+			s.Logger.Printf("http server unexpected error: %v", err)
+		}
+	}()
+	<-serverClosed
+	return err
+}

+ 79 - 0
web/serve_test.go

@@ -0,0 +1,79 @@
+package web
+
+import (
+	"encoding/json"
+	"io"
+	"log"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"github.com/gorilla/mux"
+	"golang.org/x/exp/slices"
+
+	"code.osinet.fr/fgm/lbc/domain"
+)
+
+func TestServer_Serve(t *testing.T) {
+	logger := &log.Logger{}
+	logger.SetOutput(io.Discard)
+	const base = "/test"
+
+	happyPath := filepath.Join(base, "2", "3", "7", domain.Fizz, domain.Buzz)
+	expectedHappy := []string{
+		"1",
+		domain.Fizz,
+		domain.Buzz,
+		domain.Fizz,
+		"5",
+		domain.Both,
+		"7",
+	}
+
+	tests := [...]struct {
+		name     string
+		headers  http.Header
+		path     string
+		expCode  int
+		expected []string
+	}{
+		{"happy explicit", http.Header{"Accept": []string{JSON}}, happyPath, http.StatusOK, expectedHappy},
+		{"happy wild", http.Header{"Accept": []string{"*/*"}}, happyPath, http.StatusOK, expectedHappy},
+		{"sad accept", http.Header{"Accept": []string{"text/html"}}, happyPath, http.StatusNotAcceptable, nil},
+		{"sad charset", http.Header{"Accept-Charset": []string{"iso-8859-15"}}, happyPath, http.StatusNotAcceptable, nil},
+		{"bad route params", nil, filepath.Join(base, "2", "3", "0", domain.Fizz, domain.Buzz), http.StatusNotFound, nil},
+		{"bad domain params", nil, filepath.Join(base, "2", "3", "2", domain.Fizz, domain.Buzz), http.StatusInternalServerError, nil},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req, err := http.NewRequest("GET", test.path, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			req.Header = test.headers
+			rec := httptest.NewRecorder()
+
+			// We need a router to inject variables from path, preventing us from a straight handler.ServerHTTP(rec, req)
+			router := mux.NewRouter()
+			router.HandleFunc(base+PathFB, MakeFizzBuzz(logger, true))
+			router.ServeHTTP(rec, req)
+
+			status := rec.Code
+			if status != test.expCode {
+				t.Fatalf("handler returned %d, but expected %d", status, test.expCode)
+			}
+			if status >= http.StatusMultipleChoices {
+				return
+			}
+			actual := make([]string, 0, len(test.expected))
+			err = json.Unmarshal(rec.Body.Bytes(), &actual)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if !slices.Equal(actual, test.expected) {
+				t.Errorf("handler returned:\n%#v\nbut expected:\n%#v\n", actual, test.expected)
+			}
+		})
+	}
+}

+ 71 - 0
web/stats.go

@@ -0,0 +1,71 @@
+package web
+
+import (
+	"encoding/json"
+	"net/http"
+	"path/filepath"
+	"strconv"
+	"sync"
+
+	"github.com/gorilla/mux"
+)
+
+// Stats implement a statistics counter for hits to the FizzBuzz endpoint.
+type Stats struct {
+	sync.Mutex
+	hits    map[string]int
+	maxKey  string
+	maxHits int
+}
+
+// Incr records a hit in a way that supports reporting in O(1) time and space.
+func (s *Stats) Incr(int1, int2, limit int, str1, str2 string) {
+	s.Lock()
+	defer s.Unlock()
+	key := filepath.Join(strconv.Itoa(int1), strconv.Itoa(int2), strconv.Itoa(limit), str1, str2)
+	s.hits[key]++
+	if s.hits[key] <= s.maxHits {
+		return
+	}
+	s.maxHits = s.hits[key]
+	s.maxKey = key
+}
+
+// MarshalJSON implements [json.Marshaler].
+func (s *Stats) MarshalJSON() ([]byte, error) {
+	raw := map[string]any{
+		"key":  s.maxKey,
+		"hits": s.maxHits,
+	}
+	return json.Marshal(raw)
+}
+
+// Handler provides the statistics endpoint.
+func (s *Stats) Handler() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(CT, JSON)
+		enc := json.NewEncoder(w)
+		enc.SetEscapeHTML(true)
+		enc.SetIndent("", "  ")
+		enc.Encode(s)
+	}
+}
+
+// Middleware provides the statistics middleware.
+func (s *Stats) Middleware() mux.MiddlewareFunc {
+	return func(h http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			int1, int2, limit, str1, str2, status := argsFromRequest(r)
+			if status >= http.StatusBadRequest {
+				http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+				return
+			}
+			s.Incr(int1, int2, limit, str1, str2)
+			h.ServeHTTP(w, r)
+		})
+	}
+}
+
+func NewStats() *Stats {
+	return &Stats{hits: make(map[string]int)}
+}