From e22a7daacc4ee31fef71bedd3b139b55929768a5 Mon Sep 17 00:00:00 2001 From: Kamaal Farah Date: Sun, 5 Apr 2026 15:11:45 +0200 Subject: [PATCH] Add swaggo/swag OpenAPI spec generation Co-authored-by: kamaal111 <37084924+kamaal111@users.noreply.github.com> --- .github/workflows/ci.yml | 9 ++ .gitignore | 1 + AGENTS.md | 4 + docs/docs.go | 240 ++++++++++++++++++++++++++++++++++++++ docs/embed.go | 6 + docs/swagger.json | 216 ++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 143 +++++++++++++++++++++++ go.mod | 13 +++ go.sum | 60 ++++++++++ handlers/named_symbols.go | 10 ++ handlers/openapi.go | 20 ++++ handlers/openapi_test.go | 33 ++++++ handlers/rates.go | 12 ++ handlers/routes.go | 7 +- handlers/symbols.go | 10 ++ justfile | 12 +- main.go | 8 ++ routers/openapi.go | 11 ++ routers/routers.go | 1 + 19 files changed, 809 insertions(+), 7 deletions(-) create mode 100644 docs/docs.go create mode 100644 docs/embed.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml create mode 100644 handlers/openapi.go create mode 100644 handlers/openapi_test.go create mode 100644 routers/openapi.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48e83fe..9bd028f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: env: PNPM_VERSION: 10.23.0 + SWAG_VERSION: v1.16.6 concurrency: group: ${{ github.ref }} @@ -42,5 +43,13 @@ jobs: - name: Install pnpm dependencies run: pnpm install + - name: Install swag + run: go install github.com/swaggo/swag/cmd/swag@${{ env.SWAG_VERSION }} + + - name: Check OpenAPI docs are up to date + run: | + swag init -g main.go --parseDependency --parseInternal + git diff --exit-code docs/ + - name: Run tests run: pnpm run test:all diff --git a/.gitignore b/.gitignore index 1f894e4..531a1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules firestore-debug.log firebase-debug.log ui-debug.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index a0b7b8f..1aa82b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,10 @@ - `just test` or `npm run test:unit` — unit tests only. - `just test-integration` or `npm test` — integration tests via emulator. - `just test-all` or `npm run test:all` — unit + integration. +- OpenAPI docs: + - `just generate-docs` — regenerate `docs/` from swagger annotations (requires `swag` CLI: `go install github.com/swaggo/swag/cmd/swag@latest`). + - Generated files (`docs/docs.go`, `docs/swagger.json`, `docs/swagger.yaml`) must be committed; CI verifies they are in sync. + - The spec is served at runtime via `GET /openapi.yaml`. - Docker: - `just build` then `just run` (maps port and sets envs). diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..37f3741 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,240 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/openapi.yaml": { + "get": { + "description": "Returns the OpenAPI specification for this API in YAML format.", + "produces": [ + "application/x-yaml" + ], + "tags": [ + "openapi" + ], + "summary": "Download OpenAPI spec", + "responses": { + "200": { + "description": "OpenAPI spec in YAML format", + "schema": { + "type": "string" + } + } + } + } + }, + "/v1/currencies": { + "get": { + "description": "Returns all available currencies with their human-readable names and currency signs.", + "produces": [ + "application/json" + ], + "tags": [ + "currencies" + ], + "summary": "Get currencies with names and signs", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CurrenciesRecord" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.Error" + } + } + } + } + }, + "/v1/rates/latest": { + "get": { + "description": "Get the latest currency exchange rates, optionally filtered by base currency and target symbols.", + "produces": [ + "application/json" + ], + "tags": [ + "rates" + ], + "summary": "Get latest exchange rates", + "parameters": [ + { + "type": "string", + "description": "Base currency code (default: EUR)", + "name": "base", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of target currency symbols", + "name": "symbols", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ExchangeRateRecord" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.Error" + } + } + } + } + }, + "/v1/rates/symbols": { + "get": { + "description": "Returns a list of all available currency symbols.", + "produces": [ + "application/json" + ], + "tags": [ + "rates" + ], + "summary": "Get available currency symbols", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SymbolsRecord" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.Error" + } + } + } + } + } + }, + "definitions": { + "handlers.CurrenciesRecord": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.NamedSymbol" + } + }, + "date": { + "type": "string" + } + } + }, + "handlers.ExchangeRateRecord": { + "type": "object", + "properties": { + "base": { + "type": "string" + }, + "date": { + "type": "string" + }, + "rates": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + }, + "handlers.NamedSymbol": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sign": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "handlers.SymbolsRecord": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "symbols": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "utils.Error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8000", + BasePath: "/", + Schemes: []string{}, + Title: "Forex API", + Description: "API for fetching currency exchange rates.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 0000000..4d3cbc1 --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,6 @@ +package docs + +import _ "embed" + +//go:embed swagger.yaml +var SwaggerYAML []byte diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..450a5d7 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,216 @@ +{ + "swagger": "2.0", + "info": { + "description": "API for fetching currency exchange rates.", + "title": "Forex API", + "contact": {}, + "version": "1.0" + }, + "host": "localhost:8000", + "basePath": "/", + "paths": { + "/openapi.yaml": { + "get": { + "description": "Returns the OpenAPI specification for this API in YAML format.", + "produces": [ + "application/x-yaml" + ], + "tags": [ + "openapi" + ], + "summary": "Download OpenAPI spec", + "responses": { + "200": { + "description": "OpenAPI spec in YAML format", + "schema": { + "type": "string" + } + } + } + } + }, + "/v1/currencies": { + "get": { + "description": "Returns all available currencies with their human-readable names and currency signs.", + "produces": [ + "application/json" + ], + "tags": [ + "currencies" + ], + "summary": "Get currencies with names and signs", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CurrenciesRecord" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.Error" + } + } + } + } + }, + "/v1/rates/latest": { + "get": { + "description": "Get the latest currency exchange rates, optionally filtered by base currency and target symbols.", + "produces": [ + "application/json" + ], + "tags": [ + "rates" + ], + "summary": "Get latest exchange rates", + "parameters": [ + { + "type": "string", + "description": "Base currency code (default: EUR)", + "name": "base", + "in": "query" + }, + { + "type": "string", + "description": "Comma-separated list of target currency symbols", + "name": "symbols", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ExchangeRateRecord" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.Error" + } + } + } + } + }, + "/v1/rates/symbols": { + "get": { + "description": "Returns a list of all available currency symbols.", + "produces": [ + "application/json" + ], + "tags": [ + "rates" + ], + "summary": "Get available currency symbols", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SymbolsRecord" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/utils.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/utils.Error" + } + } + } + } + } + }, + "definitions": { + "handlers.CurrenciesRecord": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.NamedSymbol" + } + }, + "date": { + "type": "string" + } + } + }, + "handlers.ExchangeRateRecord": { + "type": "object", + "properties": { + "base": { + "type": "string" + }, + "date": { + "type": "string" + }, + "rates": { + "type": "object", + "additionalProperties": { + "type": "number", + "format": "float64" + } + } + } + }, + "handlers.NamedSymbol": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sign": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "handlers.SymbolsRecord": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "symbols": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "utils.Error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..ef5932d --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,143 @@ +basePath: / +definitions: + handlers.CurrenciesRecord: + properties: + data: + items: + $ref: '#/definitions/handlers.NamedSymbol' + type: array + date: + type: string + type: object + handlers.ExchangeRateRecord: + properties: + base: + type: string + date: + type: string + rates: + additionalProperties: + format: float64 + type: number + type: object + type: object + handlers.NamedSymbol: + properties: + name: + type: string + sign: + type: string + symbol: + type: string + type: object + handlers.SymbolsRecord: + properties: + date: + type: string + symbols: + items: + type: string + type: array + type: object + utils.Error: + properties: + message: + type: string + status: + type: integer + type: object +host: localhost:8000 +info: + contact: {} + description: API for fetching currency exchange rates. + title: Forex API + version: "1.0" +paths: + /openapi.yaml: + get: + description: Returns the OpenAPI specification for this API in YAML format. + produces: + - application/x-yaml + responses: + "200": + description: OpenAPI spec in YAML format + schema: + type: string + summary: Download OpenAPI spec + tags: + - openapi + /v1/currencies: + get: + description: Returns all available currencies with their human-readable names + and currency signs. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.CurrenciesRecord' + "404": + description: Not Found + schema: + $ref: '#/definitions/utils.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.Error' + summary: Get currencies with names and signs + tags: + - currencies + /v1/rates/latest: + get: + description: Get the latest currency exchange rates, optionally filtered by + base currency and target symbols. + parameters: + - description: 'Base currency code (default: EUR)' + in: query + name: base + type: string + - description: Comma-separated list of target currency symbols + in: query + name: symbols + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ExchangeRateRecord' + "404": + description: Not Found + schema: + $ref: '#/definitions/utils.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.Error' + summary: Get latest exchange rates + tags: + - rates + /v1/rates/symbols: + get: + description: Returns a list of all available currency symbols. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.SymbolsRecord' + "404": + description: Not Found + schema: + $ref: '#/definitions/utils.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/utils.Error' + summary: Get available currency symbols + tags: + - rates +swagger: "2.0" diff --git a/go.mod b/go.mod index 7b37000..70873f5 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25 require ( cloud.google.com/go/firestore v1.20.0 + github.com/swaggo/swag v1.16.6 google.golang.org/api v0.256.0 ) @@ -13,12 +14,21 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -26,15 +36,18 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 4aa4ac1..73c9b03 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,16 @@ cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= @@ -26,6 +34,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -38,12 +56,33 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= @@ -62,18 +101,29 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= @@ -88,5 +138,15 @@ google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/named_symbols.go b/handlers/named_symbols.go index c4a8208..7533ef0 100644 --- a/handlers/named_symbols.go +++ b/handlers/named_symbols.go @@ -9,6 +9,16 @@ import ( "github.com/kamaal111/forex-api/utils" ) +// GetCurrencies handles requests for currencies with names and signs. +// +// @Summary Get currencies with names and signs +// @Description Returns all available currencies with their human-readable names and currency signs. +// @Tags currencies +// @Produce json +// @Success 200 {object} CurrenciesRecord +// @Failure 404 {object} utils.Error +// @Failure 500 {object} utils.Error +// @Router /v1/currencies [get] func GetCurrencies(writer http.ResponseWriter, request *http.Request) { ctx := context.Background() client, err := database.CreateClient(ctx) diff --git a/handlers/openapi.go b/handlers/openapi.go new file mode 100644 index 0000000..8d931b7 --- /dev/null +++ b/handlers/openapi.go @@ -0,0 +1,20 @@ +package handlers + +import ( + "net/http" + + forexdocs "github.com/kamaal111/forex-api/docs" +) + +// GetOpenAPISpec serves the OpenAPI specification in YAML format. +// +// @Summary Download OpenAPI spec +// @Description Returns the OpenAPI specification for this API in YAML format. +// @Tags openapi +// @Produce application/x-yaml +// @Success 200 {string} string "OpenAPI spec in YAML format" +// @Router /openapi.yaml [get] +func GetOpenAPISpec(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/x-yaml") + writer.Write(forexdocs.SwaggerYAML) +} diff --git a/handlers/openapi_test.go b/handlers/openapi_test.go new file mode 100644 index 0000000..661ad28 --- /dev/null +++ b/handlers/openapi_test.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetOpenAPISpecHandler(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, OpenAPISpecPath, nil) + recorder := httptest.NewRecorder() + + GetOpenAPISpec(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("GetOpenAPISpec() status = %d, want %d", recorder.Code, http.StatusOK) + } + + contentType := recorder.Header().Get("Content-Type") + if contentType != "application/x-yaml" { + t.Errorf("GetOpenAPISpec() Content-Type = %q, want %q", contentType, "application/x-yaml") + } + + body := recorder.Body.String() + if len(body) == 0 { + t.Error("GetOpenAPISpec() returned empty body") + } + + if !strings.Contains(body, "swagger:") && !strings.Contains(body, "openapi:") { + t.Error("GetOpenAPISpec() response does not appear to be a valid OpenAPI spec") + } +} diff --git a/handlers/rates.go b/handlers/rates.go index f57e7e2..94bcc58 100644 --- a/handlers/rates.go +++ b/handlers/rates.go @@ -79,6 +79,18 @@ func (r *FirestoreRatesRepository) GetAllSymbols() (*SymbolsRecord, error) { var ErrRatesNotFound = errors.New("rates not found") +// GetLatest handles requests for the latest exchange rates. +// +// @Summary Get latest exchange rates +// @Description Get the latest currency exchange rates, optionally filtered by base currency and target symbols. +// @Tags rates +// @Produce json +// @Param base query string false "Base currency code (default: EUR)" +// @Param symbols query string false "Comma-separated list of target currency symbols" +// @Success 200 {object} ExchangeRateRecord +// @Failure 404 {object} utils.Error +// @Failure 500 {object} utils.Error +// @Router /v1/rates/latest [get] func GetLatest(writer http.ResponseWriter, request *http.Request) { ctx := context.Background() client, err := database.CreateClient(ctx) diff --git a/handlers/routes.go b/handlers/routes.go index 366366e..caaf2ab 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -1,7 +1,8 @@ package handlers const ( - LatestPath = "/v1/rates/latest" - SymbolsPath = "/v1/rates/symbols" - CurrenciesPath = "/v1/currencies" + LatestPath = "/v1/rates/latest" + SymbolsPath = "/v1/rates/symbols" + CurrenciesPath = "/v1/currencies" + OpenAPISpecPath = "/openapi.yaml" ) diff --git a/handlers/symbols.go b/handlers/symbols.go index 0f487fd..5a077ec 100644 --- a/handlers/symbols.go +++ b/handlers/symbols.go @@ -9,6 +9,16 @@ import ( "github.com/kamaal111/forex-api/utils" ) +// GetSymbols handles requests for available currency symbols. +// +// @Summary Get available currency symbols +// @Description Returns a list of all available currency symbols. +// @Tags rates +// @Produce json +// @Success 200 {object} SymbolsRecord +// @Failure 404 {object} utils.Error +// @Failure 500 {object} utils.Error +// @Router /v1/rates/symbols [get] func GetSymbols(writer http.ResponseWriter, request *http.Request) { ctx := context.Background() client, err := database.CreateClient(ctx) diff --git a/justfile b/justfile index 07bae7f..7adf526 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,3 @@ -# Start the Firestore emulator manually -start-db: - gcloud emulators firestore start - # Run the development server with hot reload dev: #!/bin/sh @@ -12,6 +8,10 @@ dev: ~/go/bin/reflex -r '\.go' -s -- sh -c "go run ." +# Start the Firestore emulator manually +start-db: + gcloud emulators firestore start + # Run unit tests only (no emulator needed) test: go test ./... -v -short @@ -32,6 +32,10 @@ test-integration: test-all: pnpm run test:all +# Generate OpenAPI documentation from swagger annotations +generate-docs: + swag init -g main.go --parseDependency --parseInternal + # Build the Docker image build: docker build -t forex-api . diff --git a/main.go b/main.go index d16a15f..7c560f9 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,11 @@ +// Package main is the entry point for the Forex API. +// +// @title Forex API +// @version 1.0 +// @description API for fetching currency exchange rates. +// +// @host localhost:8000 +// @BasePath / package main import ( diff --git a/routers/openapi.go b/routers/openapi.go new file mode 100644 index 0000000..b6e3c62 --- /dev/null +++ b/routers/openapi.go @@ -0,0 +1,11 @@ +package routers + +import ( + "net/http" + + "github.com/kamaal111/forex-api/handlers" +) + +func openapiGroup(mux *http.ServeMux) { + mux.Handle(handlers.OpenAPISpecPath, loggerMiddleware(http.HandlerFunc(handlers.GetOpenAPISpec))) +} diff --git a/routers/routers.go b/routers/routers.go index fc69733..1e426f2 100644 --- a/routers/routers.go +++ b/routers/routers.go @@ -18,6 +18,7 @@ func Start() { mux := http.NewServeMux() ratesGroup(mux) currenciesGroup(mux) + openapiGroup(mux) mux.Handle("/", loggerMiddleware(http.HandlerFunc(notFound))) log.Printf("Listening on %s...", serverAddress)