From 7d59235d081a78b93403bb4bf6bb45ac4e7d6bb9 Mon Sep 17 00:00:00 2001 From: Cedric Kienzler Date: Wed, 8 Mar 2023 16:25:53 +0100 Subject: [PATCH] merge API to url-shortener --- docs/docs.go | 375 +++++++++++++++++++ docs/swagger.json | 375 +++++++++++++++++++ docs/swagger.yaml | 272 ++++++++++++++ pkg/client/authenticated_shortlink_client.go | 104 +++++ pkg/controller/handle_create_shortlink.go | 99 +++++ pkg/controller/handle_delete_shortlink.go | 87 +++++ pkg/controller/handle_get_shortlink.go | 78 ++++ pkg/controller/handle_list_shortlink.go | 87 +++++ pkg/controller/handle_shortlink.go | 99 +++++ pkg/controller/handle_update_shortlink.go | 107 ++++++ pkg/controller/helper.go | 56 +++ pkg/controller/shortlink-controller.go | 110 +----- pkg/router/router.go | 25 ++ 13 files changed, 1774 insertions(+), 100 deletions(-) create mode 100644 pkg/client/authenticated_shortlink_client.go create mode 100644 pkg/controller/handle_create_shortlink.go create mode 100644 pkg/controller/handle_delete_shortlink.go create mode 100644 pkg/controller/handle_get_shortlink.go create mode 100644 pkg/controller/handle_list_shortlink.go create mode 100644 pkg/controller/handle_shortlink.go create mode 100644 pkg/controller/handle_update_shortlink.go diff --git a/docs/docs.go b/docs/docs.go index a38eca7..f368377 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -24,6 +24,309 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/shortlink/": { + "get": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "list shortlinks", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "list shortlinks", + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.ShortLink" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + } + }, + "/api/v1/shortlink/{shortlink}": { + "get": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "get a shortlink", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "get a shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/controller.ShortLink" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + }, + "put": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "update a new shortlink", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "update existing shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path", + "required": true + }, + { + "description": "shortlink spec", + "name": "spec", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1.ShortLinkSpec" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "integer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + }, + "post": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "create a new shortlink", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "create new shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path" + }, + { + "description": "shortlink spec", + "name": "spec", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1.ShortLinkSpec" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "integer" + } + }, + "301": { + "description": "MovedPermanently", + "schema": { + "type": "integer" + } + }, + "302": { + "description": "Found", + "schema": { + "type": "integer" + } + }, + "307": { + "description": "TemporaryRedirect", + "schema": { + "type": "integer" + } + }, + "308": { + "description": "PermanentRedirect", + "schema": { + "type": "integer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + }, + "delete": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "delete shortlink", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "delete shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "integer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + } + }, "/{shortlink}": { "get": { "description": "redirect to target as per configuration of the shortlink", @@ -113,6 +416,78 @@ const docTemplate = `{ } } } + }, + "definitions": { + "controller.ShortLink": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "spec": { + "$ref": "#/definitions/v1alpha1.ShortLinkSpec" + }, + "status": { + "$ref": "#/definitions/v1alpha1.ShortLinkStatus" + } + } + }, + "v1alpha1.ShortLinkSpec": { + "type": "object", + "properties": { + "after": { + "description": "RedirectAfter specifies after how many seconds to redirect (Default=3)\n+kubebuilder:default:=0\n+kubebuilder:validation:Minimum=0\n+kubebuilder:validation:Maximum=99", + "type": "integer" + }, + "code": { + "description": "Code is the URL Code used for the redirection.\nleave on default (307) when using the HTML behavior. However, if you whish to use a HTTP 3xx redirect, set to the appropriate 3xx status code\n+kubebuilder:validation:Enum=200;300;301;302;303;304;305;307;308\n+kubebuilder:default:=307", + "type": "integer", + "enum": [ + 200, + 300, + 301, + 302, + 303, + 304, + 305, + 307, + 308 + ] + }, + "owner": { + "description": "Owner is the GitHub user name which created the shortlink\n+kubebuilder:validation:Required", + "type": "string" + }, + "owners": { + "description": "Co-Owners are the GitHub user name which can also administrate this shortlink\n+kubebuilder:validation:Optional", + "type": "array", + "items": { + "type": "string" + } + }, + "target": { + "description": "Target specifies the target to which we will redirect\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", + "type": "string" + } + } + }, + "v1alpha1.ShortLinkStatus": { + "type": "object", + "properties": { + "changedby": { + "description": "ChangedBy indicates who (GitHub User) changed the Shortlink last\n+kubebuilder:validation:Optional", + "type": "string" + }, + "count": { + "description": "Count represents how often this ShortLink has been called\n+kubebuilder:default:=0\n+kubebuilder:validation:Minimum=0", + "type": "integer" + }, + "lastmodified": { + "description": "LastModified is a date-time when the ShortLink was last modified\n+kubebuilder:validation:Format:date-time\n+kubebuilder:validation:Optional", + "type": "string" + } + } + } } }` diff --git a/docs/swagger.json b/docs/swagger.json index 3842deb..4f11087 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -16,6 +16,309 @@ }, "basePath": "/", "paths": { + "/api/v1/shortlink/": { + "get": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "list shortlinks", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "list shortlinks", + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/controller.ShortLink" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + } + }, + "/api/v1/shortlink/{shortlink}": { + "get": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "get a shortlink", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "get a shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/controller.ShortLink" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + }, + "put": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "update a new shortlink", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "update existing shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path", + "required": true + }, + { + "description": "shortlink spec", + "name": "spec", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1.ShortLinkSpec" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "integer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + }, + "post": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "create a new shortlink", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "create new shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path" + }, + { + "description": "shortlink spec", + "name": "spec", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1.ShortLinkSpec" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "integer" + } + }, + "301": { + "description": "MovedPermanently", + "schema": { + "type": "integer" + } + }, + "302": { + "description": "Found", + "schema": { + "type": "integer" + } + }, + "307": { + "description": "TemporaryRedirect", + "schema": { + "type": "integer" + } + }, + "308": { + "description": "PermanentRedirect", + "schema": { + "type": "integer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + }, + "delete": { + "security": [ + { + "bearerAuth": [] + } + ], + "description": "delete shortlink", + "produces": [ + "text/plain", + "application/json" + ], + "tags": [ + "api/v1/" + ], + "summary": "delete shortlink", + "parameters": [ + { + "type": "string", + "example": "home", + "description": "the shortlink URL part (shortlink id)", + "name": "shortlink", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "integer" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "NotFound", + "schema": { + "type": "integer" + } + }, + "500": { + "description": "InternalServerError", + "schema": { + "type": "integer" + } + } + } + } + }, "/{shortlink}": { "get": { "description": "redirect to target as per configuration of the shortlink", @@ -105,5 +408,77 @@ } } } + }, + "definitions": { + "controller.ShortLink": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "spec": { + "$ref": "#/definitions/v1alpha1.ShortLinkSpec" + }, + "status": { + "$ref": "#/definitions/v1alpha1.ShortLinkStatus" + } + } + }, + "v1alpha1.ShortLinkSpec": { + "type": "object", + "properties": { + "after": { + "description": "RedirectAfter specifies after how many seconds to redirect (Default=3)\n+kubebuilder:default:=0\n+kubebuilder:validation:Minimum=0\n+kubebuilder:validation:Maximum=99", + "type": "integer" + }, + "code": { + "description": "Code is the URL Code used for the redirection.\nleave on default (307) when using the HTML behavior. However, if you whish to use a HTTP 3xx redirect, set to the appropriate 3xx status code\n+kubebuilder:validation:Enum=200;300;301;302;303;304;305;307;308\n+kubebuilder:default:=307", + "type": "integer", + "enum": [ + 200, + 300, + 301, + 302, + 303, + 304, + 305, + 307, + 308 + ] + }, + "owner": { + "description": "Owner is the GitHub user name which created the shortlink\n+kubebuilder:validation:Required", + "type": "string" + }, + "owners": { + "description": "Co-Owners are the GitHub user name which can also administrate this shortlink\n+kubebuilder:validation:Optional", + "type": "array", + "items": { + "type": "string" + } + }, + "target": { + "description": "Target specifies the target to which we will redirect\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", + "type": "string" + } + } + }, + "v1alpha1.ShortLinkStatus": { + "type": "object", + "properties": { + "changedby": { + "description": "ChangedBy indicates who (GitHub User) changed the Shortlink last\n+kubebuilder:validation:Optional", + "type": "string" + }, + "count": { + "description": "Count represents how often this ShortLink has been called\n+kubebuilder:default:=0\n+kubebuilder:validation:Minimum=0", + "type": "integer" + }, + "lastmodified": { + "description": "LastModified is a date-time when the ShortLink was last modified\n+kubebuilder:validation:Format:date-time\n+kubebuilder:validation:Optional", + "type": "string" + } + } + } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6c20b58..38fd30c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,79 @@ basePath: / +definitions: + controller.ShortLink: + properties: + name: + type: string + spec: + $ref: '#/definitions/v1alpha1.ShortLinkSpec' + status: + $ref: '#/definitions/v1alpha1.ShortLinkStatus' + type: object + v1alpha1.ShortLinkSpec: + properties: + after: + description: |- + RedirectAfter specifies after how many seconds to redirect (Default=3) + +kubebuilder:default:=0 + +kubebuilder:validation:Minimum=0 + +kubebuilder:validation:Maximum=99 + type: integer + code: + description: |- + Code is the URL Code used for the redirection. + leave on default (307) when using the HTML behavior. However, if you whish to use a HTTP 3xx redirect, set to the appropriate 3xx status code + +kubebuilder:validation:Enum=200;300;301;302;303;304;305;307;308 + +kubebuilder:default:=307 + enum: + - 200 + - 300 + - 301 + - 302 + - 303 + - 304 + - 305 + - 307 + - 308 + type: integer + owner: + description: |- + Owner is the GitHub user name which created the shortlink + +kubebuilder:validation:Required + type: string + owners: + description: |- + Co-Owners are the GitHub user name which can also administrate this shortlink + +kubebuilder:validation:Optional + items: + type: string + type: array + target: + description: |- + Target specifies the target to which we will redirect + +kubebuilder:validation:Required + +kubebuilder:validation:MinLength=1 + type: string + type: object + v1alpha1.ShortLinkStatus: + properties: + changedby: + description: |- + ChangedBy indicates who (GitHub User) changed the Shortlink last + +kubebuilder:validation:Optional + type: string + count: + description: |- + Count represents how often this ShortLink has been called + +kubebuilder:default:=0 + +kubebuilder:validation:Minimum=0 + type: integer + lastmodified: + description: |- + LastModified is a date-time when the ShortLink was last modified + +kubebuilder:validation:Format:date-time + +kubebuilder:validation:Optional + type: string + type: object info: contact: email: urlshortener@cedi.dev @@ -70,4 +145,201 @@ paths: summary: redirect to target tags: - default + /api/v1/shortlink/: + get: + description: list shortlinks + produces: + - text/plain + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/controller.ShortLink' + type: array + "401": + description: Unauthorized + schema: + type: integer + "404": + description: NotFound + schema: + type: integer + "500": + description: InternalServerError + schema: + type: integer + security: + - bearerAuth: [] + summary: list shortlinks + tags: + - api/v1/ + /api/v1/shortlink/{shortlink}: + delete: + description: delete shortlink + parameters: + - description: the shortlink URL part (shortlink id) + example: home + in: path + name: shortlink + required: true + type: string + produces: + - text/plain + - application/json + responses: + "200": + description: Success + schema: + type: integer + "401": + description: Unauthorized + schema: + type: integer + "404": + description: NotFound + schema: + type: integer + "500": + description: InternalServerError + schema: + type: integer + security: + - bearerAuth: [] + summary: delete shortlink + tags: + - api/v1/ + get: + description: get a shortlink + parameters: + - description: the shortlink URL part (shortlink id) + example: home + in: path + name: shortlink + type: string + produces: + - text/plain + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/controller.ShortLink' + "401": + description: Unauthorized + schema: + type: integer + "404": + description: NotFound + schema: + type: integer + "500": + description: InternalServerError + schema: + type: integer + security: + - bearerAuth: [] + summary: get a shortlink + tags: + - api/v1/ + post: + consumes: + - application/json + description: create a new shortlink + parameters: + - description: the shortlink URL part (shortlink id) + example: home + in: path + name: shortlink + type: string + - description: shortlink spec + in: body + name: spec + required: true + schema: + $ref: '#/definitions/v1alpha1.ShortLinkSpec' + produces: + - text/plain + - application/json + responses: + "200": + description: Success + schema: + type: integer + "301": + description: MovedPermanently + schema: + type: integer + "302": + description: Found + schema: + type: integer + "307": + description: TemporaryRedirect + schema: + type: integer + "308": + description: PermanentRedirect + schema: + type: integer + "401": + description: Unauthorized + schema: + type: integer + "404": + description: NotFound + schema: + type: integer + "500": + description: InternalServerError + schema: + type: integer + security: + - bearerAuth: [] + summary: create new shortlink + tags: + - api/v1/ + put: + consumes: + - application/json + description: update a new shortlink + parameters: + - description: the shortlink URL part (shortlink id) + example: home + in: path + name: shortlink + required: true + type: string + - description: shortlink spec + in: body + name: spec + required: true + schema: + $ref: '#/definitions/v1alpha1.ShortLinkSpec' + produces: + - text/plain + - application/json + responses: + "200": + description: Success + schema: + type: integer + "401": + description: Unauthorized + schema: + type: integer + "404": + description: NotFound + schema: + type: integer + "500": + description: InternalServerError + schema: + type: integer + security: + - bearerAuth: [] + summary: update existing shortlink + tags: + - api/v1/ swagger: "2.0" diff --git a/pkg/client/authenticated_shortlink_client.go b/pkg/client/authenticated_shortlink_client.go new file mode 100644 index 0000000..199d9b1 --- /dev/null +++ b/pkg/client/authenticated_shortlink_client.go @@ -0,0 +1,104 @@ +package client + +import ( + "context" + + "github.com/cedi/urlshortener/api/v1alpha1" + "github.com/go-logr/logr" + "go.opentelemetry.io/otel/trace" + "golang.org/x/exp/slices" +) + +type ShortlinkClientAuth struct { + log *logr.Logger + tracer trace.Tracer + client *ShortlinkClient +} + +func NewAuthenticatedShortlinkClient(log *logr.Logger, tracer trace.Tracer, client *ShortlinkClient) *ShortlinkClientAuth { + return &ShortlinkClientAuth{ + log: log, + tracer: tracer, + client: client, + } +} + +func (c *ShortlinkClientAuth) List(ct context.Context, username string) (*v1alpha1.ShortLinkList, error) { + ctx, span := c.tracer.Start(ct, "ShortlinkClientAuth.List") + defer span.End() + + list, err := c.client.List(ctx) + if err != nil { + return nil, err + } + + userShortlinkList := v1alpha1.ShortLinkList{ + TypeMeta: list.TypeMeta, + ListMeta: list.ListMeta, + Items: make([]v1alpha1.ShortLink, 0), + } + + for _, shortLink := range list.Items { + if shortLink.IsOwnedBy(username) { + userShortlinkList.Items = append(userShortlinkList.Items, shortLink) + } + } + + return &userShortlinkList, nil +} + +func (c *ShortlinkClientAuth) Get(ct context.Context, username string, name string) (*v1alpha1.ShortLink, error) { + ctx, span := c.tracer.Start(ct, "ShortlinkClientAuth.Get") + defer span.End() + + shortLink, err := c.client.Get(ctx, name) + if err != nil { + return nil, err + } + + if !shortLink.IsOwnedBy(username) { + return nil, nil + } + + return shortLink, nil +} + +func (c *ShortlinkClientAuth) Create(ct context.Context, username string, shortLink *v1alpha1.ShortLink) error { + ctx, span := c.tracer.Start(ct, "ShortlinkClientAuth.Create") + defer span.End() + + shortLink.Spec.Owner = username + + return c.client.Create(ctx, shortLink) +} + +func (c *ShortlinkClientAuth) Update(ct context.Context, username string, shortLink *v1alpha1.ShortLink) error { + ctx, span := c.tracer.Start(ct, "ShortlinkClientAuth.Update") + defer span.End() + + // When someone updates a shortlink and removes himself as the owner + // add him to the CoOwner + if shortLink.Spec.Owner != username { + if !slices.Contains(shortLink.Spec.CoOwners, username) { + shortLink.Spec.CoOwners = append(shortLink.Spec.CoOwners, username) + } + } + + if err := c.client.Update(ctx, shortLink); err != nil { + return err + } + + shortLink.Status.ChangedBy = username + return c.client.UpdateStatus(ctx, shortLink) +} + +func (c *ShortlinkClientAuth) Delete(ct context.Context, username string, shortLink *v1alpha1.ShortLink) error { + ctx, span := c.tracer.Start(ct, "ShortlinkClientAuth.Update") + defer span.End() + + if !shortLink.IsOwnedBy(username) { + return nil + } + + return c.client.Delete(ctx, shortLink) +} diff --git a/pkg/controller/handle_create_shortlink.go b/pkg/controller/handle_create_shortlink.go new file mode 100644 index 0000000..437e260 --- /dev/null +++ b/pkg/controller/handle_create_shortlink.go @@ -0,0 +1,99 @@ +package controller + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/cedi/urlshortener/api/v1alpha1" + "github.com/cedi/urlshortener/pkg/observability" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HandleCreateShortLink handles the creation of a shortlink and redirects according to the configuration +// @BasePath /api/v1/ +// @Summary create new shortlink +// @Schemes http https +// @Description create a new shortlink +// @Accept application/json +// @Produce text/plain +// @Produce application/json +// @Param shortlink path string false "the shortlink URL part (shortlink id)" example(home) +// @Param spec body v1alpha1.ShortLinkSpec true "shortlink spec" +// @Success 200 {object} int "Success" +// @Success 301 {object} int "MovedPermanently" +// @Success 302 {object} int "Found" +// @Success 307 {object} int "TemporaryRedirect" +// @Success 308 {object} int "PermanentRedirect" +// @Failure 401 {object} int "Unauthorized" +// @Failure 404 {object} int "NotFound" +// @Failure 500 {object} int "InternalServerError" +// @Tags api/v1/ +// @Router /api/v1/shortlink/{shortlink} [post] +// @Security bearerAuth +func (s *ShortlinkController) HandleCreateShortLink(c *gin.Context) { + shortlinkName := c.Param("shortlink") + contentType := c.Request.Header.Get("accept") + + // Call the HTML method of the Context to render a template + ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleGetShortLink", trace.WithAttributes(attribute.String("shortlink", shortlinkName), attribute.String("accepted_content_type", contentType))) + defer span.End() + + bearerToken := c.Request.Header.Get("Authorization") + bearerToken = strings.TrimPrefix(bearerToken, "Bearer") + bearerToken = strings.TrimPrefix(bearerToken, "token") + if len(bearerToken) == 0 { + err := fmt.Errorf("no credentials provided") + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + githubUser, err := getGitHubUserInfo(ctx, bearerToken) + if err != nil { + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + shortlink := v1alpha1.ShortLink{ + ObjectMeta: v1.ObjectMeta{ + Name: shortlinkName, + }, + Spec: v1alpha1.ShortLinkSpec{}, + } + + jsonData, err := io.ReadAll(c.Request.Body) + if err != nil { + observability.RecordError(span, s.log, err, "Failed to read request-body") + ginReturnError(c, http.StatusInternalServerError, contentType, err.Error()) + return + } + + if err := json.Unmarshal([]byte(jsonData), &shortlink.Spec); err != nil { + observability.RecordError(span, s.log, err, "Failed to read spec-json") + ginReturnError(c, http.StatusInternalServerError, contentType, err.Error()) + return + } + + if err := s.authenticatedClient.Create(ctx, githubUser.Login, &shortlink); err != nil { + observability.RecordError(span, s.log, err, "Failed to create ShortLink") + ginReturnError(c, http.StatusInternalServerError, contentType, err.Error()) + return + } + + if contentType == ContentTypeTextPlain { + c.Data(http.StatusOK, contentType, []byte(fmt.Sprintf("%s: %s\n", shortlink.Name, shortlink.Spec.Target))) + } else if contentType == ContentTypeApplicationJSON { + c.JSON(http.StatusOK, ShortLink{ + Name: shortlink.Name, + Spec: shortlink.Spec, + Status: shortlink.Status, + }) + } +} diff --git a/pkg/controller/handle_delete_shortlink.go b/pkg/controller/handle_delete_shortlink.go new file mode 100644 index 0000000..fc95c9c --- /dev/null +++ b/pkg/controller/handle_delete_shortlink.go @@ -0,0 +1,87 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cedi/urlshortener/pkg/observability" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// HandleDeleteShortLink handles the deletion of a shortlink +// @BasePath /api/v1/ +// @Summary delete shortlink +// @Schemes http https +// @Description delete shortlink +// @Produce text/plain +// @Produce application/json +// @Param shortlink path string true "the shortlink URL part (shortlink id)" example(home) +// @Success 200 {object} int "Success" +// @Failure 401 {object} int "Unauthorized" +// @Failure 404 {object} int "NotFound" +// @Failure 500 {object} int "InternalServerError" +// @Tags api/v1/ +// @Router /api/v1/shortlink/{shortlink} [delete] +// @Security bearerAuth +func (s *ShortlinkController) HandleDeleteShortLink(c *gin.Context) { + shortlinkName := c.Param("shortlink") + + contentType := c.Request.Header.Get("accept") + + // Call the HTML method of the Context to render a template + ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleGetShortLink", trace.WithAttributes(attribute.String("shortlink", shortlinkName), attribute.String("accepted_content_type", contentType))) + defer span.End() + + bearerToken := c.Request.Header.Get("Authorization") + bearerToken = strings.TrimPrefix(bearerToken, "Bearer") + bearerToken = strings.TrimPrefix(bearerToken, "token") + if len(bearerToken) == 0 { + err := fmt.Errorf("no credentials provided") + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + githubUser, err := getGitHubUserInfo(ctx, bearerToken) + if err != nil { + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + shortlink, err := s.authenticatedClient.Get(ctx, githubUser.Login, shortlinkName) + if err != nil { + observability.RecordError(span, s.log, err, "Failed to get ShortLink") + + statusCode := http.StatusInternalServerError + + if strings.Contains(err.Error(), "not found") { + statusCode = http.StatusNotFound + } + + ginReturnError(c, statusCode, contentType, err.Error()) + return + } + + // When shortlink was not found + if shortlink == nil { + ginReturnError(c, http.StatusNotFound, contentType, "Shortlink not found") + return + } + + if err := s.authenticatedClient.Delete(ctx, githubUser.Login, shortlink); err != nil { + statusCode := http.StatusInternalServerError + + if strings.Contains(err.Error(), "not found") { + statusCode = http.StatusNotFound + } + + observability.RecordError(span, s.log, err, "Failed to delete ShortLink") + + ginReturnError(c, statusCode, contentType, err.Error()) + return + } +} diff --git a/pkg/controller/handle_get_shortlink.go b/pkg/controller/handle_get_shortlink.go new file mode 100644 index 0000000..e99ae69 --- /dev/null +++ b/pkg/controller/handle_get_shortlink.go @@ -0,0 +1,78 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cedi/urlshortener/pkg/observability" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// HandleGetShortLink returns the shortlink +// @BasePath /api/v1/ +// @Summary get a shortlink +// @Schemes http https +// @Description get a shortlink +// @Produce text/plain +// @Produce application/json +// @Param shortlink path string false "the shortlink URL part (shortlink id)" example(home) +// @Success 200 {object} ShortLink "Success" +// @Failure 401 {object} int "Unauthorized" +// @Failure 404 {object} int "NotFound" +// @Failure 500 {object} int "InternalServerError" +// @Tags api/v1/ +// @Router /api/v1/shortlink/{shortlink} [get] +// @Security bearerAuth +func (s *ShortlinkController) HandleGetShortLink(c *gin.Context) { + shortlinkName := c.Param("shortlink") + + contentType := c.Request.Header.Get("accept") + + // Call the HTML method of the Context to render a template + ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleGetShortLink", trace.WithAttributes(attribute.String("shortlink", shortlinkName), attribute.String("accepted_content_type", contentType))) + defer span.End() + + bearerToken := c.Request.Header.Get("Authorization") + bearerToken = strings.TrimPrefix(bearerToken, "Bearer") + bearerToken = strings.TrimPrefix(bearerToken, "token") + if len(bearerToken) == 0 { + err := fmt.Errorf("no credentials provided") + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + githubUser, err := getGitHubUserInfo(ctx, bearerToken) + if err != nil { + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + shortlink, err := s.authenticatedClient.Get(ctx, githubUser.Login, shortlinkName) + if err != nil { + observability.RecordError(span, s.log, err, "Failed to get ShortLink") + + statusCode := http.StatusInternalServerError + + if strings.Contains(err.Error(), "not found") { + statusCode = http.StatusNotFound + } + + ginReturnError(c, statusCode, contentType, err.Error()) + return + } + + if contentType == ContentTypeTextPlain { + c.Data(http.StatusOK, contentType, []byte(shortlink.Spec.Target)) + } else if contentType == ContentTypeApplicationJSON { + c.JSON(http.StatusOK, ShortLink{ + Name: shortlink.Name, + Spec: shortlink.Spec, + Status: shortlink.Status, + }) + } +} diff --git a/pkg/controller/handle_list_shortlink.go b/pkg/controller/handle_list_shortlink.go new file mode 100644 index 0000000..c8af549 --- /dev/null +++ b/pkg/controller/handle_list_shortlink.go @@ -0,0 +1,87 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cedi/urlshortener/pkg/observability" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// HandleListShortLink handles the listing of +// @BasePath /api/v1/ +// @Summary list shortlinks +// @Schemes http https +// @Description list shortlinks +// @Produce text/plain +// @Produce application/json +// @Success 200 {object} []ShortLink "Success" +// @Failure 401 {object} int "Unauthorized" +// @Failure 404 {object} int "NotFound" +// @Failure 500 {object} int "InternalServerError" +// @Tags api/v1/ +// @Router /api/v1/shortlink/ [get] +// @Security bearerAuth +func (s *ShortlinkController) HandleListShortLink(c *gin.Context) { + contentType := c.Request.Header.Get("accept") + + trace.SpanFromContext(c) + + // Call the HTML method of the Context to render a template + ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleListShortLink", trace.WithAttributes(attribute.String("accepted_content_type", contentType))) + defer span.End() + + bearerToken := c.Request.Header.Get("Authorization") + bearerToken = strings.TrimPrefix(bearerToken, "Bearer") + bearerToken = strings.TrimPrefix(bearerToken, "token") + if len(bearerToken) == 0 { + err := fmt.Errorf("no credentials provided") + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + githubUser, err := getGitHubUserInfo(ctx, bearerToken) + if err != nil { + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + shortlinkList, err := s.authenticatedClient.List(ctx, githubUser.Login) + if err != nil { + observability.RecordError(span, s.log, err, "Failed to list ShortLink") + + statusCode := http.StatusInternalServerError + + if strings.Contains(err.Error(), "not found") { + statusCode = http.StatusNotFound + } + + ginReturnError(c, statusCode, contentType, err.Error()) + return + } + + targetList := make([]ShortLink, len(shortlinkList.Items)) + + for idx, shortlink := range shortlinkList.Items { + targetList[idx] = ShortLink{ + Name: shortlink.ObjectMeta.Name, + Spec: shortlink.Spec, + Status: shortlink.Status, + } + } + + if contentType == ContentTypeApplicationJSON { + c.JSON(http.StatusOK, targetList) + } else if contentType == ContentTypeTextPlain { + shortLinks := "" + for _, shortlink := range targetList { + shortLinks += fmt.Sprintf("%s: %s\n", shortlink.Name, shortlink.Spec.Target) + } + c.Data(http.StatusOK, contentType, []byte(shortLinks)) + } +} diff --git a/pkg/controller/handle_shortlink.go b/pkg/controller/handle_shortlink.go new file mode 100644 index 0000000..61e8c59 --- /dev/null +++ b/pkg/controller/handle_shortlink.go @@ -0,0 +1,99 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cedi/urlshortener/pkg/observability" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// HandleShortlink handles the shortlink and redirects according to the configuration +// @BasePath / +// @Summary redirect to target +// @Schemes http https +// @Description redirect to target as per configuration of the shortlink +// @Produce text/html +// @Param shortlink path string true "shortlink id" +// @Success 200 {object} int "Success" +// @Success 300 {object} int "MultipleChoices" +// @Success 301 {object} int "MovedPermanently" +// @Success 302 {object} int "Found" +// @Success 303 {object} int "SeeOther" +// @Success 304 {object} int "NotModified" +// @Success 305 {object} int "UseProxy" +// @Success 307 {object} int "TemporaryRedirect" +// @Success 308 {object} int "PermanentRedirect" +// @Failure 404 {object} int "NotFound" +// @Failure 500 {object} int "InternalServerError" +// @Tags default +// @Router /{shortlink} [get] +func (s *ShortlinkController) HandleShortLink(c *gin.Context) { + shortlinkName := c.Param("shortlink") + + // Call the HTML method of the Context to render a template + ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleShortLink", trace.WithAttributes(attribute.String("shortlink", shortlinkName))) + defer span.End() + + span.AddEvent("shortlink", trace.WithAttributes(attribute.String("shortlink", shortlinkName))) + + c.Header("Cache-Control", "public, max-age=900, stale-if-error=3600") // max-age = 15min; stale-if-error = 1h + + shortlink, err := s.client.Get(ctx, shortlinkName) + if err != nil { + if strings.Contains(err.Error(), "not found") { + observability.RecordError(span, s.log, err, "Path not found") + span.SetAttributes(attribute.String("path", c.Request.URL.Path)) + + c.HTML(http.StatusNotFound, "404.html", gin.H{}) + } else { + observability.RecordError(span, s.log, err, "Failed to get ShortLink") + c.HTML(http.StatusInternalServerError, "500.html", gin.H{}) + } + return + } + + span.SetAttributes( + attribute.String("Target", shortlink.Spec.Target), + attribute.Int64("RedirectAfter", shortlink.Spec.RedirectAfter), + attribute.Int("InvocationCount", shortlink.Status.Count), + ) + + target := shortlink.Spec.Target + + if !strings.HasPrefix(target, "http") { + target = fmt.Sprintf("http://%s", target) + + span.AddEvent("change prefix", trace.WithAttributes( + attribute.String("from", shortlink.Spec.Target), + attribute.String("to", target), + )) + } + + if shortlink.Spec.Code != 200 { + // Redirect + c.Redirect(shortlink.Spec.Code, target) + } else { + // Redirect via JS/HTML + c.HTML( + // Set the HTTP status to 200 (OK) + http.StatusOK, + + // Use the index.html template + "redirect.html", + + // Pass the data that the page uses (in this case, 'title') + gin.H{ + "redirectFrom": c.Request.URL.Path, + "redirectTo": target, + "redirectAfter": shortlink.Spec.RedirectAfter, + }, + ) + } + + // Increase hit counter + s.client.IncrementInvocationCount(ctx, shortlink) +} diff --git a/pkg/controller/handle_update_shortlink.go b/pkg/controller/handle_update_shortlink.go new file mode 100644 index 0000000..1ecb52b --- /dev/null +++ b/pkg/controller/handle_update_shortlink.go @@ -0,0 +1,107 @@ +package controller + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/cedi/urlshortener/api/v1alpha1" + "github.com/cedi/urlshortener/pkg/observability" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// HandleDeleteShortLink handles the update of a shortlink +// @BasePath /api/v1/ +// @Summary update existing shortlink +// @Schemes http https +// @Description update a new shortlink +// @Accept application/json +// @Produce text/plain +// @Produce application/json +// @Param shortlink path string true "the shortlink URL part (shortlink id)" example(home) +// @Param spec body v1alpha1.ShortLinkSpec true "shortlink spec" +// @Success 200 {object} int "Success" +// @Failure 401 {object} int "Unauthorized" +// @Failure 404 {object} int "NotFound" +// @Failure 500 {object} int "InternalServerError" +// @Tags api/v1/ +// @Router /api/v1/shortlink/{shortlink} [put] +// @Security bearerAuth +func (s *ShortlinkController) HandleUpdateShortLink(c *gin.Context) { + shortlinkName := c.Param("shortlink") + + contentType := c.Request.Header.Get("accept") + + // Call the HTML method of the Context to render a template + ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleGetShortLink", trace.WithAttributes(attribute.String("shortlink", shortlinkName), attribute.String("accepted_content_type", contentType))) + defer span.End() + + bearerToken := c.Request.Header.Get("Authorization") + bearerToken = strings.TrimPrefix(bearerToken, "Bearer") + bearerToken = strings.TrimPrefix(bearerToken, "token") + if len(bearerToken) == 0 { + err := fmt.Errorf("no credentials provided") + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + githubUser, err := getGitHubUserInfo(ctx, bearerToken) + if err != nil { + span.RecordError(err) + ginReturnError(c, http.StatusUnauthorized, contentType, err.Error()) + return + } + + shortlink, err := s.authenticatedClient.Get(ctx, githubUser.Login, shortlinkName) + if err != nil { + observability.RecordError(span, s.log, err, "Failed to get ShortLink") + + statusCode := http.StatusInternalServerError + + if strings.Contains(err.Error(), "not found") { + statusCode = http.StatusNotFound + } + + ginReturnError(c, statusCode, contentType, err.Error()) + return + } + + // When shortlink was not found + if shortlink == nil { + ginReturnError(c, http.StatusNotFound, contentType, "Shortlink not found") + return + } + + shortlinkSpec := v1alpha1.ShortLinkSpec{} + + jsonData, err := ioutil.ReadAll(c.Request.Body) + if err != nil { + observability.RecordError(span, s.log, err, "Failed to read request-body") + + ginReturnError(c, http.StatusInternalServerError, contentType, err.Error()) + return + } + + if err := json.Unmarshal([]byte(jsonData), &shortlinkSpec); err != nil { + observability.RecordError(span, s.log, err, "Failed to read ShortLink Spec JSON") + + ginReturnError(c, http.StatusInternalServerError, contentType, err.Error()) + return + } + + shortlink.Spec = shortlinkSpec + + if err := s.authenticatedClient.Update(ctx, githubUser.Login, shortlink); err != nil { + observability.RecordError(span, s.log, err, "Failed to update ShortLink") + + ginReturnError(c, http.StatusInternalServerError, contentType, err.Error()) + return + } + + ginReturnError(c, http.StatusOK, contentType, "") +} diff --git a/pkg/controller/helper.go b/pkg/controller/helper.go index ce0d044..536690b 100644 --- a/pkg/controller/helper.go +++ b/pkg/controller/helper.go @@ -1,8 +1,15 @@ package controller import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "github.com/cedi/urlshortener/api/v1alpha1" "github.com/gin-gonic/gin" + "github.com/pkg/errors" ) const ( @@ -21,6 +28,15 @@ type JsonReturnError struct { Error string `json:"error"` } +type GithubUser struct { + Id int `json:"id,omitempty"` + Login string `json:"login,omitempty"` + Avatar_url string `json:"avatar_url,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + func ginReturnError(c *gin.Context, statusCode int, contentType string, err string) { if contentType == ContentTypeTextPlain { c.Data(statusCode, contentType, []byte(err)) @@ -31,3 +47,43 @@ func ginReturnError(c *gin.Context, statusCode int, contentType string, err stri }) } } + +func getGitHubUserInfo(c context.Context, bearerToken string) (*GithubUser, error) { + // prepare request to GitHubs User endpoint + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) + if err != nil { + return nil, errors.Wrap(err, "Failed to build request to fetch GitHub API") + } + + // Set headers + req.Header.Add("Accept", "application/vnd.github.v3+json") + req.Header.Add("Authorization", "token "+bearerToken) + + // Perform request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "Failed to fetch UserInfo from GitHub API") + } + defer resp.Body.Close() + + // If request was unsuccessful, we error out + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Bad credentials") + } + + // If successful, we read the response body + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "Error while reading the response") + } + + // And parse it in our GithubUser model + githubUser := &GithubUser{} + err = json.Unmarshal(body, githubUser) + if err != nil { + return nil, errors.Wrap(err, "Failed to unmarshal GitHub UserInfo") + } + + return githubUser, nil +} diff --git a/pkg/controller/shortlink-controller.go b/pkg/controller/shortlink-controller.go index fcde857..f54133c 100644 --- a/pkg/controller/shortlink-controller.go +++ b/pkg/controller/shortlink-controller.go @@ -1,118 +1,28 @@ package controller import ( - "fmt" - "net/http" - "strings" - shortlinkClient "github.com/cedi/urlshortener/pkg/client" - "github.com/cedi/urlshortener/pkg/observability" "github.com/go-logr/logr" - "github.com/gin-gonic/gin" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // ShortlinkController is an object who handles the requests made towards our shortlink-application type ShortlinkController struct { - client *shortlinkClient.ShortlinkClient - log *logr.Logger - tracer trace.Tracer + client *shortlinkClient.ShortlinkClient + authenticatedClient *shortlinkClient.ShortlinkClientAuth + log *logr.Logger + tracer trace.Tracer } // NewShortlinkController creates a new ShortlinkController func NewShortlinkController(log *logr.Logger, tracer trace.Tracer, client *shortlinkClient.ShortlinkClient) *ShortlinkController { - return &ShortlinkController{ - log: log, - tracer: tracer, - client: client, - } -} - -// HandleShortlink handles the shortlink and redirects according to the configuration -// @BasePath / -// @Summary redirect to target -// @Schemes http https -// @Description redirect to target as per configuration of the shortlink -// @Produce text/html -// @Param shortlink path string true "shortlink id" -// @Success 200 {object} int "Success" -// @Success 300 {object} int "MultipleChoices" -// @Success 301 {object} int "MovedPermanently" -// @Success 302 {object} int "Found" -// @Success 303 {object} int "SeeOther" -// @Success 304 {object} int "NotModified" -// @Success 305 {object} int "UseProxy" -// @Success 307 {object} int "TemporaryRedirect" -// @Success 308 {object} int "PermanentRedirect" -// @Failure 404 {object} int "NotFound" -// @Failure 500 {object} int "InternalServerError" -// @Tags default -// @Router /{shortlink} [get] -func (s *ShortlinkController) HandleShortLink(c *gin.Context) { - shortlinkName := c.Param("shortlink") - - // Call the HTML method of the Context to render a template - ctx, span := s.tracer.Start(c.Request.Context(), "ShortlinkController.HandleShortLink", trace.WithAttributes(attribute.String("shortlink", shortlinkName))) - defer span.End() - - span.AddEvent("shortlink", trace.WithAttributes(attribute.String("shortlink", shortlinkName))) - - c.Header("Cache-Control", "public, max-age=900, stale-if-error=3600") // max-age = 15min; stale-if-error = 1h - - shortlink, err := s.client.Get(ctx, shortlinkName) - if err != nil { - if strings.Contains(err.Error(), "not found") { - observability.RecordError(span, s.log, err, "Path not found") - span.SetAttributes(attribute.String("path", c.Request.URL.Path)) - - c.HTML(http.StatusNotFound, "404.html", gin.H{}) - } else { - observability.RecordError(span, s.log, err, "Failed to get ShortLink") - c.HTML(http.StatusInternalServerError, "500.html", gin.H{}) - } - return - } - - span.SetAttributes( - attribute.String("Target", shortlink.Spec.Target), - attribute.Int64("RedirectAfter", shortlink.Spec.RedirectAfter), - attribute.Int("InvocationCount", shortlink.Status.Count), - ) - - target := shortlink.Spec.Target - - if !strings.HasPrefix(target, "http") { - target = fmt.Sprintf("http://%s", target) - - span.AddEvent("change prefix", trace.WithAttributes( - attribute.String("from", shortlink.Spec.Target), - attribute.String("to", target), - )) - } - - if shortlink.Spec.Code != 200 { - // Redirect - c.Redirect(shortlink.Spec.Code, target) - } else { - // Redirect via JS/HTML - c.HTML( - // Set the HTTP status to 200 (OK) - http.StatusOK, - - // Use the index.html template - "redirect.html", - - // Pass the data that the page uses (in this case, 'title') - gin.H{ - "redirectFrom": c.Request.URL.Path, - "redirectTo": target, - "redirectAfter": shortlink.Spec.RedirectAfter, - }, - ) + controller := &ShortlinkController{ + log: log, + tracer: tracer, + client: client, + authenticatedClient: shortlinkClient.NewAuthenticatedShortlinkClient(log, tracer, client), } - // Increase hit counter - s.client.IncrementInvocationCount(ctx, shortlink) + return controller } diff --git a/pkg/router/router.go b/pkg/router/router.go index 3362aa1..64657b2 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -17,6 +17,22 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" ) +// @title URL Shortener +// @version 1.0 +// @description A url shortener, written in Go running on Kubernetes + +// @contact.name Cedric Kienzler +// @contact.url cedi.dev +// @contact.email urlshortener-api@cedi.dev + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath / + +// @securityDefinitions.apiKey bearerAuth +// @in header +// @name Authorization + func NewGinGonicHTTPServer(setupLog *logr.Logger, bindAddr string) (*gin.Engine, *http.Server) { router := gin.New() router.Use( @@ -53,4 +69,13 @@ func Load(router *gin.Engine, shortlinkController *urlShortenerController.Shortl router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET("/:shortlink", shortlinkController.HandleShortLink) + + { + v1 := router.Group("/api/v1") + v1.GET("/shortlink/", shortlinkController.HandleListShortLink) + v1.GET("/shortlink/:shortlink", shortlinkController.HandleGetShortLink) + v1.POST("/shortlink/:shortlink", shortlinkController.HandleCreateShortLink) + v1.PUT("/shortlink/:shortlink", shortlinkController.HandleUpdateShortLink) + v1.DELETE("/shortlink/:shortlink", shortlinkController.HandleDeleteShortLink) + } }