1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-06-15 00:15:15 +02:00

Completed signup package and hooked up to web-api. Can use the swagger

ui to signup a new account.
This commit is contained in:
Lee Brown
2019-06-25 02:40:29 -08:00
parent 957bd9bf36
commit 2fbda74a73
21 changed files with 1110 additions and 167 deletions

View File

@ -1,16 +1,26 @@
FROM golang:alpine3.9 AS build_base
FROM golang:1.12.6-alpine3.9 AS build_base
LABEL maintainer="lee@geeksinthewoods.com"
RUN apk --update --no-cache add \
git
git build-base gcc
RUN go get -u github.com/swaggo/swag/cmd/swag
# Hack to get swag init to work correctly.
RUN GO111MODULE=off go get gopkg.in/go-playground/validator.v9 && \
GO111MODULE=off go get github.com/go-playground/universal-translator && \
GO111MODULE=off go get github.com/leodido/go-urn && \
GO111MODULE=off go get github.com/lib/pq/oid && \
GO111MODULE=off go get github.com/lib/pq/scram && \
GO111MODULE=off go get github.com/tinylib/msgp/msgp && \
GO111MODULE=off go get gopkg.in/DataDog/dd-trace-go.v1/ddtrace
# go to base project
# Install swag with go modules enabled.
RUN GO111MODULE=on go get -u github.com/swaggo/swag/cmd/swag
# Change dir to project base.
WORKDIR $GOPATH/src/gitlab.com/geeks-accelerator/oss/saas-starter-kit/example-project
# enable go modules
# Enable go modules.
ENV GO111MODULE="on"
COPY go.mod .
COPY go.sum .
@ -18,10 +28,10 @@ RUN go mod download
FROM build_base AS builder
# copy shared packages
# Copy shared packages.
COPY internal ./internal
# copy cmd specific package
# Copy cmd specific packages.
COPY cmd/web-api ./cmd/web-api
COPY cmd/web-api/templates /templates
#COPY cmd/web-api/static /static

View File

@ -1,6 +1,6 @@
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag at
// 2019-06-24 20:15:37.524606 -0800 AKDT m=+13.872100491
// 2019-06-25 02:19:21.144417 -0800 AKDT m=+51.040366621
package docs
@ -16,10 +16,10 @@ var doc = `{
"info": {
"description": "This is a sample server celler server.",
"title": "SaaS Example API",
"termsOfService": "/terms",
"termsOfService": "http://example.com/terms",
"contact": {
"name": "API Support",
"url": "/support",
"url": "http://example.com/support",
"email": "support@geeksinthewoods.com"
},
"license": {
@ -40,6 +40,9 @@ var doc = `{
"produces": [
"application/json"
],
"tags": [
"account"
],
"summary": "Read returns the specified account from the system.",
"operationId": "get-string-by-int",
"parameters": [
@ -88,6 +91,123 @@ var doc = `{
}
}
}
},
"/signup": {
"post": {
"description": "Signup creates a new account and user in the system.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"signup"
],
"summary": "Signup handles new account creation.",
"parameters": [
{
"description": "Signup details",
"name": "data",
"in": "body",
"required": true,
"schema": {
"type": "object",
"$ref": "#/definitions/signup.SignupRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"$ref": "#/definitions/signup.SignupResponse"
},
"headers": {
"Token": {
"type": "string",
"description": "qwerty"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
}
}
}
},
"/users/{id}": {
"get": {
"description": "get string by ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Read returns the specified user from the system.",
"operationId": "get-string-by-int",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"$ref": "#/definitions/user.User"
},
"headers": {
"Token": {
"type": "string",
"description": "qwerty"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
}
}
}
}
},
"definitions": {
@ -95,10 +215,12 @@ var doc = `{
"type": "object",
"properties": {
"address1": {
"type": "string"
"type": "string",
"example": "221 Tatitlek Ave"
},
"address2": {
"type": "string"
"type": "string",
"example": "Box #1832"
},
"archived_at": {
"type": "string"
@ -107,36 +229,166 @@ var doc = `{
"type": "string"
},
"city": {
"type": "string"
"type": "string",
"example": "Valdez"
},
"country": {
"type": "string"
"type": "string",
"example": "USA"
},
"created_at": {
"type": "string"
},
"id": {
"type": "string"
"type": "string",
"example": "c4653bf9-5978-48b7-89c5-95704aebb7e2"
},
"name": {
"type": "string"
"type": "string",
"example": "Company Name"
},
"region": {
"type": "string"
"type": "string",
"example": "AK"
},
"signup_user_id": {
"type": "string"
},
"status": {
"type": "AccountStatus"
"type": "string",
"example": "active"
},
"timezone": {
"type": "string"
"type": "string",
"example": "America/Anchorage"
},
"updated_at": {
"type": "string"
},
"zipcode": {
"type": "string",
"example": "99686"
}
}
},
"signup.SignupRequest": {
"type": "object",
"properties": {
"account": {
"type": "object",
"required": [
"name",
"address1",
"city",
"region",
"country",
"zipcode"
],
"properties": {
"address1": {
"type": "string",
"example": "221 Tatitlek Ave"
},
"address2": {
"type": "string",
"example": "Box #1832"
},
"city": {
"type": "string",
"example": "Valdez"
},
"country": {
"type": "string",
"example": "USA"
},
"name": {
"type": "string",
"example": "Company {RANDOM_UUID}"
},
"region": {
"type": "string",
"example": "AK"
},
"timezone": {
"type": "string",
"example": "America/Anchorage"
},
"zipcode": {
"type": "string",
"example": "99686"
}
}
},
"user": {
"type": "object",
"required": [
"name",
"email",
"password"
],
"properties": {
"email": {
"type": "string",
"example": "{RANDOM_EMAIL}"
},
"name": {
"type": "string",
"example": "Gabi May"
},
"password": {
"type": "string",
"example": "SecretString"
},
"password_confirm": {
"type": "string",
"example": "SecretString"
}
}
}
}
},
"signup.SignupResponse": {
"type": "object",
"properties": {
"account": {
"type": "object",
"$ref": "#/definitions/account.Account"
},
"user": {
"type": "object",
"$ref": "#/definitions/user.User"
}
}
},
"user.User": {
"type": "object",
"required": [
"name"
],
"properties": {
"archived_at": {
"type": "string"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string",
"example": "gabi@geeksinthewoods.com"
},
"id": {
"type": "string",
"example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2"
},
"name": {
"type": "string",
"example": "Gabi May"
},
"timezone": {
"type": "string",
"example": "America/Anchorage"
},
"updated_at": {
"type": "string"
}
}

View File

@ -3,10 +3,10 @@
"info": {
"description": "This is a sample server celler server.",
"title": "SaaS Example API",
"termsOfService": "/terms",
"termsOfService": "http://example.com/terms",
"contact": {
"name": "API Support",
"url": "/support",
"url": "http://example.com/support",
"email": "support@geeksinthewoods.com"
},
"license": {
@ -27,6 +27,9 @@
"produces": [
"application/json"
],
"tags": [
"account"
],
"summary": "Read returns the specified account from the system.",
"operationId": "get-string-by-int",
"parameters": [
@ -75,6 +78,123 @@
}
}
}
},
"/signup": {
"post": {
"description": "Signup creates a new account and user in the system.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"signup"
],
"summary": "Signup handles new account creation.",
"parameters": [
{
"description": "Signup details",
"name": "data",
"in": "body",
"required": true,
"schema": {
"type": "object",
"$ref": "#/definitions/signup.SignupRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"$ref": "#/definitions/signup.SignupResponse"
},
"headers": {
"Token": {
"type": "string",
"description": "qwerty"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
}
}
}
},
"/users/{id}": {
"get": {
"description": "get string by ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Read returns the specified user from the system.",
"operationId": "get-string-by-int",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"$ref": "#/definitions/user.User"
},
"headers": {
"Token": {
"type": "string",
"description": "qwerty"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"$ref": "#/definitions/web.Error"
}
}
}
}
}
},
"definitions": {
@ -82,10 +202,12 @@
"type": "object",
"properties": {
"address1": {
"type": "string"
"type": "string",
"example": "221 Tatitlek Ave"
},
"address2": {
"type": "string"
"type": "string",
"example": "Box #1832"
},
"archived_at": {
"type": "string"
@ -94,36 +216,166 @@
"type": "string"
},
"city": {
"type": "string"
"type": "string",
"example": "Valdez"
},
"country": {
"type": "string"
"type": "string",
"example": "USA"
},
"created_at": {
"type": "string"
},
"id": {
"type": "string"
"type": "string",
"example": "c4653bf9-5978-48b7-89c5-95704aebb7e2"
},
"name": {
"type": "string"
"type": "string",
"example": "Company Name"
},
"region": {
"type": "string"
"type": "string",
"example": "AK"
},
"signup_user_id": {
"type": "string"
},
"status": {
"type": "AccountStatus"
"type": "string",
"example": "active"
},
"timezone": {
"type": "string"
"type": "string",
"example": "America/Anchorage"
},
"updated_at": {
"type": "string"
},
"zipcode": {
"type": "string",
"example": "99686"
}
}
},
"signup.SignupRequest": {
"type": "object",
"properties": {
"account": {
"type": "object",
"required": [
"name",
"address1",
"city",
"region",
"country",
"zipcode"
],
"properties": {
"address1": {
"type": "string",
"example": "221 Tatitlek Ave"
},
"address2": {
"type": "string",
"example": "Box #1832"
},
"city": {
"type": "string",
"example": "Valdez"
},
"country": {
"type": "string",
"example": "USA"
},
"name": {
"type": "string",
"example": "Company {RANDOM_UUID}"
},
"region": {
"type": "string",
"example": "AK"
},
"timezone": {
"type": "string",
"example": "America/Anchorage"
},
"zipcode": {
"type": "string",
"example": "99686"
}
}
},
"user": {
"type": "object",
"required": [
"name",
"email",
"password"
],
"properties": {
"email": {
"type": "string",
"example": "{RANDOM_EMAIL}"
},
"name": {
"type": "string",
"example": "Gabi May"
},
"password": {
"type": "string",
"example": "SecretString"
},
"password_confirm": {
"type": "string",
"example": "SecretString"
}
}
}
}
},
"signup.SignupResponse": {
"type": "object",
"properties": {
"account": {
"type": "object",
"$ref": "#/definitions/account.Account"
},
"user": {
"type": "object",
"$ref": "#/definitions/user.User"
}
}
},
"user.User": {
"type": "object",
"required": [
"name"
],
"properties": {
"archived_at": {
"type": "string"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string",
"example": "gabi@geeksinthewoods.com"
},
"id": {
"type": "string",
"example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2"
},
"name": {
"type": "string",
"example": "Gabi May"
},
"timezone": {
"type": "string",
"example": "America/Anchorage"
},
"updated_at": {
"type": "string"
}
}

View File

@ -3,36 +3,134 @@ definitions:
account.Account:
properties:
address1:
example: 221 Tatitlek Ave
type: string
address2:
example: 'Box #1832'
type: string
archived_at:
type: string
billing_user_id:
type: string
city:
example: Valdez
type: string
country:
example: USA
type: string
created_at:
type: string
id:
example: c4653bf9-5978-48b7-89c5-95704aebb7e2
type: string
name:
example: Company Name
type: string
region:
example: AK
type: string
signup_user_id:
type: string
status:
type: AccountStatus
example: active
type: string
timezone:
example: America/Anchorage
type: string
updated_at:
type: string
zipcode:
example: "99686"
type: string
type: object
signup.SignupRequest:
properties:
account:
properties:
address1:
example: 221 Tatitlek Ave
type: string
address2:
example: 'Box #1832'
type: string
city:
example: Valdez
type: string
country:
example: USA
type: string
name:
example: Company {RANDOM_UUID}
type: string
region:
example: AK
type: string
timezone:
example: America/Anchorage
type: string
zipcode:
example: "99686"
type: string
required:
- name
- address1
- city
- region
- country
- zipcode
type: object
user:
properties:
email:
example: '{RANDOM_EMAIL}'
type: string
name:
example: Gabi May
type: string
password:
example: SecretString
type: string
password_confirm:
example: SecretString
type: string
required:
- name
- email
- password
type: object
type: object
signup.SignupResponse:
properties:
account:
$ref: '#/definitions/account.Account'
type: object
user:
$ref: '#/definitions/user.User'
type: object
type: object
user.User:
properties:
archived_at:
type: string
created_at:
type: string
email:
example: gabi@geeksinthewoods.com
type: string
id:
example: d69bdef7-173f-4d29-b52c-3edc60baf6a2
type: string
name:
example: Gabi May
type: string
timezone:
example: America/Anchorage
type: string
updated_at:
type: string
required:
- name
type: object
web.Error:
properties:
err:
@ -49,12 +147,12 @@ info:
contact:
email: support@geeksinthewoods.com
name: API Support
url: /support
url: http://example.com/support
description: This is a sample server celler server.
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
termsOfService: /terms
termsOfService: http://example.com/terms
title: SaaS Example API
version: '{{.Version}}'
paths:
@ -98,6 +196,88 @@ paths:
$ref: '#/definitions/web.Error'
type: object
summary: Read returns the specified account from the system.
tags:
- account
/signup:
post:
consumes:
- application/json
description: Signup creates a new account and user in the system.
parameters:
- description: Signup details
in: body
name: data
required: true
schema:
$ref: '#/definitions/signup.SignupRequest'
type: object
produces:
- application/json
responses:
"200":
description: OK
headers:
Token:
description: qwerty
type: string
schema:
$ref: '#/definitions/signup.SignupResponse'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/web.Error'
type: object
"403":
description: Forbidden
schema:
$ref: '#/definitions/web.Error'
type: object
summary: Signup handles new account creation.
tags:
- signup
/users/{id}:
get:
consumes:
- application/json
description: get string by ID
operationId: get-string-by-int
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
headers:
Token:
description: qwerty
type: string
schema:
$ref: '#/definitions/user.User'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/web.Error'
type: object
"403":
description: Forbidden
schema:
$ref: '#/definitions/web.Error'
type: object
"404":
description: Not Found
schema:
$ref: '#/definitions/web.Error'
type: object
summary: Read returns the specified user from the system.
tags:
- user
securityDefinitions:
OAuth2Password:
flow: password

View File

@ -42,6 +42,7 @@ func (a *Account) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque
// Read godoc
// @Summary Read returns the specified account from the system.
// @Description get string by ID
// @Tags account
// @ID get-string-by-int
// @Accept json
// @Produce json

View File

@ -53,6 +53,12 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
app.Handle("PATCH", "/v1/accounts/:id/archive", a.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/accounts/:id", a.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
// Register signup endpoints.
s := Signup{
MasterDB: masterDB,
}
app.Handle("POST", "/v1/signup", s.Signup)
// Register project.
p := Project{
MasterDB: masterDB,

View File

@ -0,0 +1,58 @@
package handlers
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/account"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/signup"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"net/http"
)
// Signup represents the Signup API method handler set.
type Signup struct {
MasterDB *sqlx.DB
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
}
// Signup godoc
// @Summary Signup handles new account creation.
// @Description Signup creates a new account and user in the system.
// @Tags signup
// @Accept json
// @Produce json
// @Param data body signup.SignupRequest true "Signup details"
// @Success 200 {object} signup.SignupResponse
// @Header 200 {string} Token "qwerty"
// @Failure 400 {object} web.Error
// @Failure 403 {object} web.Error
// @Router /signup [post]
func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
// Claims are optional as authentication is not required ATM for this method.
claims, _ := ctx.Value(auth.Key).(auth.Claims)
var req signup.SignupRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
res, err := signup.Signup(ctx, claims, c.MasterDB, req, v.Now)
if err != nil {
switch err {
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "User: %+v", &req)
}
}
return web.RespondJson(ctx, w, res, http.StatusCreated)
}

View File

@ -44,7 +44,20 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request,
return web.RespondJson(ctx, w, res, http.StatusOK)
}
// Read returns the specified user from the system.
// Read godoc
// @Summary Read returns the specified user from the system.
// @Description get string by ID
// @Tags user
// @ID get-string-by-int
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} user.User
// @Header 200 {string} Token "qwerty"
// @Failure 400 {object} web.Error
// @Failure 403 {object} web.Error
// @Failure 404 {object} web.Error
// @Router /users/{id} [get]
func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {

View File

@ -1,14 +1,14 @@
FROM golang:alpine3.9 AS build_base
FROM golang:1.12.6-alpine3.9 AS build_base
LABEL maintainer="lee@geeksinthewoods.com"
RUN apk --update --no-cache add \
git
# go to base project
# Change dir to project base.
WORKDIR $GOPATH/src/gitlab.com/geeks-accelerator/oss/saas-starter-kit/example-project
# enable go modules
# Enable go modules.
ENV GO111MODULE="on"
COPY go.mod .
COPY go.sum .
@ -16,10 +16,10 @@ RUN go mod download
FROM build_base AS builder
# copy shared packages
# Copy shared packages.
COPY internal ./internal
# copy cmd specific package
# Copy cmd specific packages.
COPY cmd/web-app ./cmd/web-app
COPY cmd/web-app/templates /templates
COPY cmd/web-app/static /static

View File

@ -317,10 +317,10 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun
}
if req.SignupUserID != nil {
a.SignupUserID = sql.NullString{String: *req.SignupUserID, Valid: true}
a.SignupUserID = &sql.NullString{String: *req.SignupUserID, Valid: true}
}
if req.BillingUserID != nil {
a.BillingUserID = sql.NullString{String: *req.BillingUserID, Valid: true}
a.BillingUserID = &sql.NullString{String: *req.BillingUserID, Valid: true}
}
// Build the insert SQL statement.

View File

@ -12,36 +12,36 @@ import (
// Account represents someone with access to our system.
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Address1 string `json:"address1"`
Address2 string `json:"address2"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
Zipcode string `json:"zipcode"`
Status AccountStatus `json:"status"`
Timezone string `json:"timezone"`
SignupUserID sql.NullString `json:"signup_user_id"`
BillingUserID sql.NullString `json:"billing_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt pq.NullTime `json:"archived_at"`
ID string `json:"id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Name string `json:"name" example:"Company Name"`
Address1 string `json:"address1" example:"221 Tatitlek Ave"`
Address2 string `json:"address2" example:"Box #1832"`
City string `json:"city" example:"Valdez"`
Region string `json:"region" example:"AK"`
Country string `json:"country" example:"USA"`
Zipcode string `json:"zipcode" example:"99686"`
Status AccountStatus `json:"status" swaggertype:"string" example:"active"`
Timezone string `json:"timezone" example:"America/Anchorage"`
SignupUserID *sql.NullString `json:"signup_user_id,omitempty" swaggertype:"string"`
BillingUserID *sql.NullString `json:"billing_user_id,omitempty" swaggertype:"string"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
}
// AccountCreateRequest contains information needed to create a new Account.
type AccountCreateRequest struct {
Name string `json:"name" validate:"required,unique"`
Address1 string `json:"address1" validate:"required"`
Address2 string `json:"address2" validate:"omitempty"`
City string `json:"city" validate:"required"`
Region string `json:"region" validate:"required"`
Country string `json:"country" validate:"required"`
Zipcode string `json:"zipcode" validate:"required"`
Status *AccountStatus `json:"status" validate:"omitempty,oneof=active pending disabled"`
Timezone *string `json:"timezone" validate:"omitempty"`
SignupUserID *string `json:"signup_user_id" validate:"omitempty,uuid"`
BillingUserID *string `json:"billing_user_id" validate:"omitempty,uuid"`
Name string `json:"name" validate:"required,unique" example:"Company Name"`
Address1 string `json:"address1" validate:"required" example:"221 Tatitlek Ave"`
Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"`
City string `json:"city" validate:"required" example:"Valdez"`
Region string `json:"region" validate:"required" example:"AK"`
Country string `json:"country" validate:"required" example:"USA"`
Zipcode string `json:"zipcode" validate:"required" example:"99686"`
Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"active"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"`
BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"`
}
// AccountUpdateRequest defines what information may be provided to modify an existing
@ -51,29 +51,29 @@ type AccountCreateRequest struct {
// we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling.
type AccountUpdateRequest struct {
ID string `validate:"required,uuid"`
Name *string `json:"name" validate:"omitempty,unique"`
Address1 *string `json:"address1" validate:"omitempty"`
Address2 *string `json:"address2" validate:"omitempty"`
City *string `json:"city" validate:"omitempty"`
Region *string `json:"region" validate:"omitempty"`
Country *string `json:"country" validate:"omitempty"`
Zipcode *string `json:"zipcode" validate:"omitempty"`
Status *AccountStatus `json:"status" validate:"omitempty,oneof=active pending disabled"`
Timezone *string `json:"timezone" validate:"omitempty"`
SignupUserID *string `json:"signup_user_id" validate:"omitempty,uuid"`
BillingUserID *string `json:"billing_user_id" validate:"omitempty,uuid"`
ID string `json:"id" validate:"required,uuid"`
Name *string `json:"name,omitempty" validate:"omitempty,unique"`
Address1 *string `json:"address1,omitempty" validate:"omitempty"`
Address2 *string `json:"address2,omitempty" validate:"omitempty"`
City *string `json:"city,omitempty" validate:"omitempty"`
Region *string `json:"region,omitempty" validate:"omitempty"`
Country *string `json:"country,omitempty" validate:"omitempty"`
Zipcode *string `json:"zipcode,omitempty" validate:"omitempty"`
Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty"`
SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"`
BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"`
}
// AccountFindRequest defines the possible options to search for accounts. By default
// archived accounts will be excluded from response.
type AccountFindRequest struct {
Where *string `schema:"where"`
Args []interface{} `schema:"args"`
Order []string `schema:"order"`
Limit *uint `schema:"limit"`
Offset *uint `schema:"offset"`
IncludedArchived bool `schema:"included-archived"`
Where *string `json:"where"`
Args []interface{} `json:"args" swaggertype:"array,string"`
Order []string `json:"order"`
Limit *uint `json:"limit"`
Offset *uint `json:"offset"`
IncludedArchived bool `json:"included-archived"`
}
// AccountStatus represents the status of an account.

View File

@ -0,0 +1,121 @@
# SaaS swagger
Copyright 2019, Geeks Accelerator
accelerator@geeksinthewoods.com.com
## Description
saas middleware to automatically generate RESTful API documentation with Swagger 2.0.
## Usage
### Start using it
1. Add comments to your API source code, [See Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format).
2. Download [Swag](https://github.com/swaggo/swag) for Go by using:
```sh
$ go get github.com/swaggo/swag/cmd/swag
```
3. Run the [Swag](https://github.com/swaggo/swag) in your Go project root folder which contains `main.go` file, [Swag](https://github.com/swaggo/swag) will parse comments and generate required files(`docs` folder and `docs/doc.go`).
```sh_ "github.com/swaggo/echo-swagger/v2/example/docs"
$ swag init
```
4. Import following in your code:
```go
import "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger" // saas-swagger middleware
```
**Canonical example:**
```go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid"
saasSwagger "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger"
_ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger/example/docs" // docs is generated by Swag CLI, you have to import it.
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
)
// @title SaaS Example API
// @version 1.0
// @description This is a sample server celler server.
// @termsOfService http://geeksinthewoods.com/terms
// @contact.name API Support
// @contact.email support@geeksinthewoods.com
// @contact.url https://gitlab.com/geeks-accelerator/oss/saas-starter-kit
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host example-api.saas.geeksinthewoods.com
// @BasePath /v1
func main() {
// Logging
log := log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
// Configuration
...
// =========================================================================
// Start API Service
// Make a channel to listen for an interrupt or terminate signal from the OS.
// Use a buffered channel because the signal package requires it.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, mid.Trace(), mid.Logger(log), mid.Errors(log), mid.Metrics(), mid.Panics())
app.Handle("GET", "/swagger/", saasSwagger.WrapHandler)
app.Handle("GET", "/swagger/*", saasSwagger.WrapHandler)
/*
Or can use SaasWrapHandler func with configurations.
url := saasSwagger.URL("http://localhost:1323/swagger/doc.json") //The url pointing to API definition
e.GET("/swagger/*", saasSwagger.SaasWrapHandler(url))
*/
...
}
```
5. Run it, and browser to http://localhost:1323/swagger/index.html, you can see Swagger 2.0 Api documents.
### Dynamic Placeholders
To help ease use of the Swagger UI, dynamic placeholders have been added to the middleware. They are replaced on each
request before the JSON is returned to the browser. These can be used in an `example` struct tag.
1. `{RANDOM_UUID}`
Generates a random UUID.
Example:
```
Name string `json:"name" validate:"required" example:"Company {RANDOM_UUID}"`
```
2. `{RANDOM_EMAIL}`
Generate a random email address. Format will be UUID@example.com
Example:
```
Email string `json:"email" validate:"required,email" example:"{RANDOM_EMAIL}"`
```

View File

@ -2,6 +2,8 @@ package saasSwagger
import (
"context"
"fmt"
"github.com/pborman/uuid"
"html/template"
"net/http"
"regexp"
@ -92,6 +94,24 @@ func SaasWrapHandler(confs ...func(c *Config)) web.Handler {
if err != nil {
return web.NewRequestError(err, http.StatusInternalServerError)
}
// Replace the dynamic placeholder {RANDOM_UUID}
for {
if !strings.Contains(doc, "{RANDOM_UUID}") {
break
}
doc = strings.Replace(doc, "{RANDOM_UUID}", uuid.NewRandom().String(), 1)
}
// Replace the dynamic placeholder {RANDOM_EMAIL}
for {
if !strings.Contains(doc, "{RANDOM_EMAIL}") {
break
}
randEmail := fmt.Sprintf("%s@example.com", uuid.NewRandom().String())
doc = strings.Replace(doc, "{RANDOM_EMAIL}", randEmail, 1)
}
return web.RespondJson(ctx, w, []byte(doc), http.StatusOK)
default:
if strings.HasSuffix(path, ".html") {

View File

@ -41,6 +41,11 @@ func init() {
}
return name
})
f := func(fl validator.FieldLevel) bool {
return true
}
validate.RegisterValidation("unique", f)
}
// Decode reads the body of an HTTP request looking for a JSON document. The

View File

@ -10,20 +10,20 @@ import (
// Project represents a workflow.
type Project struct {
ID string `json:"id" validate:"required,uuid"`
ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"`
AccountID string `json:"account_id" validate:"required,uuid" truss:"api-create"`
Name string `json:"name" validate:"required"`
Status ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled"`
Status ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"`
CreatedAt time.Time `json:"created_at" truss:"api-read"`
UpdatedAt time.Time `json:"updated_at" truss:"api-read"`
ArchivedAt pq.NullTime `json:"archived_at" truss:"api-hide"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty" truss:"api-hide"`
}
// ProjectCreateRequest contains information needed to create a new Project.
type ProjectCreateRequest struct {
AccountID string `json:"account_id" validate:"required,uuid"`
Name string `json:"name" validate:"required"`
Status *ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled"`
Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"`
}
// ProjectUpdateRequest defines what information may be provided to modify an existing
@ -32,19 +32,19 @@ type ProjectCreateRequest struct {
// was not provided and a field that was provided as explicitly blank.
type ProjectUpdateRequest struct {
ID string `json:"id" validate:"required,uuid"`
Name *string `json:"name" validate:"omitempty"`
Status *ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled"`
Name *string `json:"name,omitempty" validate:"omitempty"`
Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"`
}
// ProjectFindRequest defines the possible options to search for projects. By default
// archived project will be excluded from response.
type ProjectFindRequest struct {
Where *string `schema:"where"`
Args []interface{} `schema:"args"`
Order []string `schema:"order"`
Limit *uint `schema:"limit"`
Offset *uint `schema:"offset"`
IncludedArchived bool `schema:"included-archived"`
Where *string `json:"where"`
Args []interface{} `json:"args" swaggertype:"array,string"`
Order []string `json:"order"`
Limit *uint `json:"limit"`
Offset *uint `json:"offset"`
IncludedArchived bool `json:"included-archived"`
}
// ProjectStatus represents the status of project.
@ -52,7 +52,6 @@ type ProjectStatus string
// ProjectStatus values define the status field of project.
const (
// ProjectStatus_Active defines the status of active for project.
ProjectStatus_Active ProjectStatus = "active"
// ProjectStatus_Disabled defines the status of disabled for project.
@ -61,7 +60,6 @@ const (
// ProjectStatus_Values provides list of valid ProjectStatus values.
var ProjectStatus_Values = []ProjectStatus{
ProjectStatus_Active,
ProjectStatus_Disabled,
}

View File

@ -7,8 +7,22 @@ import (
// SignupRequest contains information needed perform signup.
type SignupRequest struct {
Account account.AccountCreateRequest `json:"account" validate:"required"`
User user.UserCreateRequest `json:"user" validate:"required"`
Account struct {
Name string `json:"name" validate:"required,unique" example:"Company {RANDOM_UUID}"`
Address1 string `json:"address1" validate:"required" example:"221 Tatitlek Ave"`
Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"`
City string `json:"city" validate:"required" example:"Valdez"`
Region string `json:"region" validate:"required" example:"AK"`
Country string `json:"country" validate:"required" example:"USA"`
Zipcode string `json:"zipcode" validate:"required" example:"99686"`
Timezone *string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
} `json:"account" validate:"required"` // Account details.
User struct {
Name string `json:"name" validate:"required" example:"Gabi May"`
Email string `json:"email" validate:"required,email,unique" example:"{RANDOM_EMAIL}"`
Password string `json:"password" validate:"required" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password" example:"SecretString"`
} `json:"user" validate:"required"` // User details.
}
// SignupResponse contains information needed perform signup.

View File

@ -19,12 +19,6 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup
span, ctx := tracer.StartSpanFromContext(ctx, "internal.signup.Signup")
defer span.Finish()
// Default account status to active for signup if now set.
if req.Account.Status == nil {
s := account.AccountStatus_Active
req.Account.Status = &s
}
v := validator.New()
// Validate the user email address is unique in the database.
@ -64,18 +58,38 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup
var resp SignupResponse
// UserCreateRequest contains information needed to create a new User.
userReq := user.UserCreateRequest{
Name: req.User.Name,
Email: req.User.Email,
Password: req.User.Password,
PasswordConfirm: req.User.PasswordConfirm,
Timezone: req.Account.Timezone,
}
// Execute user creation.
resp.User, err = user.Create(ctx, claims, dbConn, req.User, now)
resp.User, err = user.Create(ctx, claims, dbConn, userReq, now)
if err != nil {
return nil, err
}
// Set the signup and billing user IDs for reference.
req.Account.SignupUserID = &resp.User.ID
req.Account.BillingUserID = &resp.User.ID
accountStatus := account.AccountStatus_Active
accountReq := account.AccountCreateRequest{
Name: req.Account.Name,
Address1: req.Account.Address1,
Address2: req.Account.Address2,
City: req.Account.City,
Region: req.Account.Region,
Country: req.Account.Country,
Zipcode: req.Account.Zipcode,
Status: &accountStatus,
Timezone: req.Account.Timezone,
SignupUserID: &resp.User.ID,
BillingUserID: &resp.User.ID,
}
// Execute account creation.
resp.Account, err = account.Create(ctx, claims, dbConn, req.Account, now)
resp.Account, err = account.Create(ctx, claims, dbConn, accountReq, now)
if err != nil {
return nil, err
}

View File

@ -10,28 +10,28 @@ import (
// User represents someone with access to our system.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name string `json:"name" validate:"required" example:"Gabi May"`
Email string `json:"email" example:"gabi@geeksinthewoods.com"`
PasswordSalt string `json:"-"`
PasswordHash []byte `json:"-"`
PasswordReset sql.NullString `json:"-"`
PasswordSalt string `json:"-"`
PasswordHash []byte `json:"-"`
PasswordReset *sql.NullString `json:"-"`
Timezone string `json:"timezone"`
Timezone string `json:"timezone" example:"America/Anchorage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt pq.NullTime `json:"archived_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
}
// UserCreateRequest contains information needed to create a new User.
type UserCreateRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email,unique"`
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"`
Timezone *string `json:"timezone" validate:"omitempty"`
Name string `json:"name" validate:"required" example:"Gabi May"`
Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"`
Password string `json:"password" validate:"required" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password" example:"SecretString"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
}
// UserUpdateRequest defines what information may be provided to modify an existing
@ -41,15 +41,15 @@ type UserCreateRequest struct {
// we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling.
type UserUpdateRequest struct {
ID string `validate:"required,uuid"`
Name *string `json:"name" validate:"omitempty"`
Email *string `json:"email" validate:"omitempty,email,unique"`
Timezone *string `json:"timezone" validate:"omitempty"`
ID string `json:"id" validate:"required,uuid"`
Name *string `json:"name,omitempty" validate:"omitempty"`
Email *string `json:"email,omitempty" validate:"omitempty,email,unique"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty"`
}
// UserUpdatePasswordRequest defines what information is required to update a user password.
type UserUpdatePasswordRequest struct {
ID string `validate:"required,uuid"`
ID string `json:"id" validate:"required,uuid"`
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"`
}
@ -57,16 +57,16 @@ type UserUpdatePasswordRequest struct {
// UserFindRequest defines the possible options to search for users. By default
// archived users will be excluded from response.
type UserFindRequest struct {
Where *string `schema:"where"`
Args []interface{} `schema:"args"`
Order []string `schema:"order"`
Limit *uint `schema:"limit"`
Offset *uint `schema:"offset"`
IncludedArchived bool `schema:"included-archived"`
Where *string `json:"where"`
Args []interface{} `json:"args" swaggertype:"array,string"`
Order []string `json:"order"`
Limit *uint `json:"limit"`
Offset *uint `json:"offset"`
IncludedArchived bool `json:"included-archived"`
}
// Token is the payload we deliver to users when they authenticate.
type Token struct {
Token string `json:"token"`
Token string `json:"token" validate:"required"`
claims auth.Claims `json:"-"`
}

View File

@ -17,14 +17,14 @@ import (
// application. The status will allow users to be managed on by account with users
// being global to the application.
type UserAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AccountID string `json:"account_id"`
Roles UserAccountRoles `json:"roles"`
Status UserAccountStatus `json:"status"`
ID string `json:"id" example:"72938896-a998-4258-a17b-6418dcdb80e3"`
UserID string `json:"user_id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" swaggertype:"array,string" enums:"admin,user" example:"admin"`
Status UserAccountStatus `json:"status" swaggertype:"string" enums:"active,invited,disabled" example:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt pq.NullTime `json:"archived_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
}
// CreateUserAccountRequest defines the information is needed to associate a user to an
@ -32,45 +32,45 @@ type UserAccount struct {
// on an account level. If a current entry exists in the database but is archived,
// it will be un-archived.
type CreateUserAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user"`
Status *UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled"`
UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"`
Status *UserAccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"active"`
}
// UpdateUserAccountRequest defines the information needed to update the roles or the
// status for an existing user account.
type UpdateUserAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
Roles *UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user"`
Status *UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled"`
UserID string `json:"user_id" validate:"required,uuid"`
AccountID string `json:"account_id" validate:"required,uuid"`
Roles *UserAccountRoles `json:"roles,omitempty" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"user"`
Status *UserAccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"disabled"`
unArchive bool `json:"-"` // Internal use only.
}
// ArchiveUserAccountRequest defines the information needed to remove an existing account
// for a user. This will archive (soft-delete) the existing database entry.
type ArchiveUserAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
UserID string `json:"user_id" validate:"required,uuid"`
AccountID string `json:"account_id" validate:"required,uuid"`
}
// DeleteUserAccountRequest defines the information needed to delete an existing account
// for a user. This will hard delete the existing database entry.
type DeleteUserAccountRequest struct {
UserID string `validate:"required,uuid"`
AccountID string `validate:"required,uuid"`
UserID string `json:"user_id" validate:"required,uuid"`
AccountID string `json:"account_id" validate:"required,uuid"`
}
// UserAccountFindRequest defines the possible options to search for users accounts.
// By default archived user accounts will be excluded from response.
type UserAccountFindRequest struct {
Where *string `schema:"where"`
Args []interface{} `schema:"args"`
Order []string `schema:"order"`
Limit *uint `schema:"limit"`
Offset *uint `schema:"offset"`
IncludedArchived bool `schema:"included-archived"`
Where *string `json:"where"`
Args []interface{} `json:"args" swaggertype:"array,string"`
Order []string `json:"order"`
Limit *uint `json:"limit"`
Offset *uint `json:"offset"`
IncludedArchived bool `json:"included-archived"`
}
// UserAccountStatus represents the status of a user for an account.

View File

@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/account"
"github.com/lib/pq"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
@ -258,7 +257,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Create
ua = *existing[0]
ua.Roles = req.Roles
ua.UpdatedAt = now
ua.ArchivedAt = pq.NullTime{}
ua.ArchivedAt = nil
} else {
ua = UserAccount{
ID: uuid.NewRandom().String(),

View File

@ -352,7 +352,7 @@ func TestCreateExistingEntry(t *testing.T) {
if err != nil || arcRes == nil {
t.Log("\t\tGot :", err)
t.Fatalf("\t%s\tFind user account failed.", tests.Failed)
} else if findRes.ArchivedAt.Valid && !findRes.ArchivedAt.Time.IsZero() {
} else if findRes.ArchivedAt != nil && findRes.ArchivedAt.Valid && !findRes.ArchivedAt.Time.IsZero() {
t.Fatalf("\t%s\tExpected user account to have archived_at empty", tests.Failed)
}
@ -657,7 +657,7 @@ func TestCrud(t *testing.T) {
Status: ua.Status,
CreatedAt: ua.CreatedAt,
UpdatedAt: now,
ArchivedAt: pq.NullTime{Time: now, Valid: true},
ArchivedAt: &pq.NullTime{Time: now, Valid: true},
},
}
if diff := cmp.Diff(findRes, expected); diff != "" {