diygoapi

package module
v0.52.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 22, 2023 License: MIT Imports: 15 Imported by: 0

README

DIY Go API

A RESTful API template (built with Go)

The goal of this project is to be an example of a relational database-backed REST HTTP Web Server that has characteristics needed to ensure success in a high volume environment. This project co-opts the DIY ethos of the Go community and does its best to "use the standard library" whenever possible, bringing in third-party libraries when not doing so would be unduly burdensome (structured logging, Oauth2, etc.).

I struggled a lot with parsing the myriad different patterns people have for package layouts over the past few years and have tried to coalesce what I've learned from others into my own take on a package layout. Below, I hope to communicate how this structure works. If you have any questions, open an issue or send me a note - I'm happy to help! Also, if you disagree or have suggestions, please do the same, I really enjoy getting both positive and negative feedback.

Go Reference Go Report Card

API Walkthrough

The following is an in-depth walkthrough of this project. This is a demo API, so the "business" intent of it is to support basic CRUD (Create, Read, Update, Delete) operations for a movie database. All paths to files or directories are from the project root.

Minimum Requirements


Getting Started

The following are basic instructions for getting started. For detailed explanations of many of the constructs created as part of these steps, jump to the Project Walkthrough

Step 1 - Get the code

Clone the code:

$ git clone https://github.com/gilcrest/diygoapi.git
Cloning into 'diygoapi'...

or use the Github CLI (also written in Go!):

$ gh repo clone gilcrest/diygoapi
Cloning into 'diygoapi'...
Step 2 - Authentication and Authorization
Authentication

All requests with this demo webserver require authentication. I have chosen to use Google's Oauth2 solution for these APIs. To use this, you need to setup a Client ID and Client Secret and obtain an access token. The instructions here are great.

After Oauth2 setup with Google, I recommend the Google Oauth2 Playground to obtain fresh access tokens for testing.

Once a user has authenticated through this flow, all calls to services require that the Google access token be sent as a Bearer token in the Authorization header.

  • If there is no token present, an HTTP 401 (Unauthorized) response will be sent and the response body will be empty.
  • If a token is properly sent, the Google Oauth2 v2 API is used to validate the token. If the token is invalid, an HTTP 401 (Unauthorized) response will be sent and the response body will be empty.
Authorization
  • If the token is valid, Google will respond with information about the user. The user's email will be used as their username in addition to determining if the user is authorized for access to a particular endpoint/resource. The authorization is done through an internal database role based access control model. If the user is not authorized to use the API, an HTTP 403 (Forbidden) response will be sent and the response body will be empty.

Step 3 - Prepare Environment (2 options)

All Mage programs in this project which take an environment (env) parameter (e.g., func DBUp(env string)), must have certain environment variables set. These environment variables can be set independently option 1 or based on a configuration file option 2. Depending on which environment method you choose, the values to pass to the env parameter when running Mage programs in this project are as follows:

env string File Path Description
current N/A Uses the current session environment. Environment will not be overriden from a config file
local ./config/local.json Uses the local.json config file to set the environment
staging ./config/staging.json Uses the staging.json config file to set the environment in Google Cloud Run

The base environment variables to be set are:

Environment Variable Description
PORT Port the server will listen on
LOG_LEVEL zerolog logging level (debug, info, etc.)
LOG_LEVEL_MIN sets the minimum accepted logging level
LOG_ERROR_STACK If true, log error stacktrace using github.com/pkg/errors, else just log error (includes op stack)
DB_HOST The host name of the database server.
DB_PORT The port number the database server is listening on.
DB_NAME The database name.
DB_USER PostgreSQL™ user name to connect as.
DB_PASSWORD Password to be used if the server demands password authentication.
DB_SEARCH_PATH Schema Search Path
ENCRYPT_KEY Encryption Key

The same environment variables are used when running the web server, but are not mandatory. When running the web server, if you prefer, you can bypass environment variables and instead send command line flags (more about that later).

Generate a new encryption key

Either option below for setting the environment requires a 256-bit ciphertext string, which can be parsed to a 32 byte encryption key. Generate the ciphertext with the NewKey mage program:

$ mage -v newkey
Running target: NewKey
Key Ciphertext: [31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06]

Copy the key ciphertext between the brackets to your clipboard to use in option 1 or 2 below

Option 1 - Set your environment independently

As always, you can set your environment on your own through bash or whatever strategy you use for this, an example bash script should you choose:

#!/bin/bash

# encryption key
export ENCRYPT_KEY="31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06"

# server listen port
export PORT="8080"

# logger environment variables
export LOG_LEVEL_MIN="trace"
export LOG_LEVEL="debug"
export LOG_ERROR_STACK="false"

# Database Environment variables
export DB_HOST="localhost"
export DB_PORT="5432"
export DB_NAME="dga_local"
export DB_USER="demo_user"
export DB_PASSWORD="REPLACE_ME"
export DB_SEARCH_PATH="demo"
Option 2 - Set your environment through a Config File
Generate new config file using CUE

Another option is to use a JSON configuration file generated by CUE located at ./config/local.json.

In order to generate this file, edit the ./config/cue/local.cue file. Paste and overwrite the ciphertext from your clipboard into the config: encryptionKey: field of the file and also update the config: database: fields (host, port, name, user, password, searchPath) as appropriate for your PostgreSQL installation.

package config

config: #LocalConfig

config: encryptionKey: "31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06"

config: httpServer: listenPort: 8080

config: logger: minLogLevel:   "trace"
config: logger: logLevel:      "debug"
config: logger: logErrorStack: false

config: database: host:       "localhost"
config: database: port:       5432
config: database: name:       "dga_local"
config: database: user:       "demo_user"
config: database: password:   "REPLACE_ME"
config: database: searchPath: "demo"

Security Disclaimer: Config files make local development easier, however, putting any credentials (encryption keys, username and password, etc.) in a config file is a bad idea from a security perspective. At a minimum, you should have the config/ directory added to your .gitignore file so these configs are not checked in. As this is a template repo, I have checked this all in for example purposes only. The data there is bogus. In an upcoming release, I will integrate with a secrets management platform like GCP Secret Manager or HashiCorp Vault Issue 91.

After modifying the above file, run the following from project root:

$ mage -v cueGenerateConfig local
Running target: CueGenerateConfig
exec: cue "vet" "./config/cue/schema.cue" "./config/cue/local.cue"
exec: cue "fmt" "./config/cue/schema.cue" "./config/cue/local.cue"
exec: cue "export" "./config/cue/schema.cue" "./config/cue/local.cue" "--force" "--out" "json" "--outfile" "./config/local.json"

cueGenerateConfig should produce a JSON config file at ./config/local.json that looks similar to:

{
    "config": {
        "httpServer": {
            "listenPort": 8080
        },
        "logger": {
            "minLogLevel": "trace",
            "logLevel": "debug",
            "logErrorStack": false
        },
        "encryptionKey": "31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06",
        "database": {
            "host": "localhost",
            "port": 5432,
            "name": "dga_local",
            "user": "demo_user",
            "password": "REPLACE_ME",
            "searchPath": "demo"
        }
    }
}

Setting the schema search path properly is critical as the objects in the migration scripts intentionally do not have qualified object names and will therefore use the search path when creating or dropping objects (in the case of the db down migration).

Step 4 - Database Initialization

The following steps setup the database objects and initialize data needed for running the web server. As a convenience, database migration programs which create these objects and load initial data can be executed using Mage. To understand database migrations and how they are structured in this project, you can watch this talk I gave to the Boston Golang meetup group in February 2022. The below examples assume you have already setup PostgreSQL and know what user, database and schema you want to install the objects.

If you want to create an isolated database and schema, you can find examples of doing that at ./scripts/db/db_init.sql.

Run the Database Up Migration

Twelve database tables are created as part of the up migration.

$ mage -v dbup local
Running target: DBUp
exec: psql "-w" "-d" "postgresql://demo_user@localhost:5432/dga_local?options=-csearch_path%3Ddemo" "-c" "select current_database(), current_user, version()" "-f" "./scripts/db/migrations/up/001-app.sql" "-f" "./scripts/db/migrations/up/002-org_user.sql" "-f" "./scripts/db/migrations/up/003-permission.sql" "-f" "./scripts/db/migrations/up/004-person.sql" "-f" "./scripts/db/migrations/up/005-org_kind.sql" "-f" "./scripts/db/migrations/up/006-role.sql" "-f" "./scripts/db/migrations/up/014-movie.sql" "-f" "./scripts/db/migrations/up/008-app_api_key.sql" "-f" "./scripts/db/migrations/up/009-person_profile.sql" "-f" "./scripts/db/migrations/up/010-org.sql" "-f" "./scripts/db/migrations/up/011-role_permission.sql" "-f" "./scripts/db/migrations/up/012-role_user.sql"
 current_database | current_user |                                                      version                                                      
------------------+--------------+-------------------------------------------------------------------------------------------------------------------
 dga_local        | demo_user    | PostgreSQL 14.2 on aarch64-apple-darwin20.6.0, compiled by Apple clang version 12.0.5 (clang-1205.0.22.9), 64-bit
(1 row)

CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
CREATE INDEX
CREATE INDEX
CREATE TABLE
COMMENT
COMMENT
COMMENT
...

Note: At any time, you can drop all the database objects created as part of the up migration using using the down migration program: mage -v dbdown local

Data Initialization (Genesis)

There are a number of tables that require initialization of data to facilitate things like: authentication through role based access controls, tracking which applications/users are interacting with the system, etc. I have bundled this initialization into a Genesis service, which can be run only once per database. This can be run as a service, but for ease of use, there is a mage program for it as well.

The genesis mage program uses a JSON configuration file generated by CUE located at ./config/genesis/request.json.

To generate this file, navigate to ./config/genesis/cue/input.cue and update the user details you plan to authenticate with via Google Oauth2. If you wish, you can update the initial org and app details from the default values you'll find in the file as well:

package genesis

// The "genesis" user - the first user to create the system and is
// given the sysAdmin role (which has all permissions). This user is
// added to the Principal org and the user initiated org created below.
user: email:      "otto.maddox@gmail.com"
user: first_name: "Otto"
user: last_name:  "Maddox"

// The first organization created which can actually transact
// (e.g. is not the principal or test org)
org: name:        "Movie Makers Unlimited"
org: description: "An organization dedicated to creating movies in a demo app."
org: kind:        "standard"

// The initial app created along with the Organization created above
org: app: name:        "Movie Makers App"
org: app: description: "The first app dedicated to creating movies in a demo app."

Next, use mage to run the cueGenerateGenesisConfig program:

$ mage -v cueGenerateGenesisConfig
Running target: CueGenerateGenesisConfig
exec: cue "vet" "./config/genesis/cue/schema.cue" "./config/genesis/cue/auth.cue" "./config/genesis/cue/input.cue"
exec: cue "fmt" "./config/genesis/cue/schema.cue" "./config/genesis/cue/auth.cue" "./config/genesis/cue/input.cue"
exec: cue "export" "./config/genesis/cue/schema.cue" "./config/genesis/cue/auth.cue" "./config/genesis/cue/input.cue" "--force" "--out" "json" "--outfile" "./config/genesis/request.json"

This will generate ./config/genesis/request.json similar to the below. This file also includes information about which permissions and roles to create as part of Genesis. Leave those as is.

{
    "user": {
        "email": "otto.maddox@gmail.com",
        "first_name": "Otto",
        "last_name": "Maddox"
    },
    "org": {
        "name": "Movie Makers Unlimited",
        "description": "An organization dedicated to creating movies in a demo app.",
        "kind": "standard",
        "app": {
            "name": "Movie Makers App",
            "description": "The first app dedicated to creating movies in a demo app."
        }
    },
    "permissions": [
        {
            "resource": "/api/v1/ping",
            "operation": "GET",
            "description": "allows for calling the ping service to determine if system is up and running",
            "active": true
        },
        {
            "resource": "/api/v1/logger",
            "operation": "GET",
            "description": "allows for reading the logger state",
            "active": true
        },
        {
            "resource": "/api/v1/logger",
            "operation": "PUT",
            "description": "allows for updating the logger state",
            "active": true
        },
        {
            "resource": "/api/v1/orgs",
            "operation": "POST",
...

Execute the Genesis mage program to initialize the database with dependent data:

$ mage -v genesis local
Running target: Genesis
{"level":"info","time":1654723891,"severity":"INFO","message":"minimum accepted logging level set to trace"}
{"level":"info","time":1654723891,"severity":"INFO","message":"logging level set to debug"}
{"level":"info","time":1654723891,"severity":"INFO","message":"log error stack global set to true"}
{"level":"info","time":1654723891,"severity":"INFO","message":"sql database opened for localhost on port 5432"}
{"level":"info","time":1654723891,"severity":"INFO","message":"sql database Ping returned successfully"}
{"level":"info","time":1654723891,"severity":"INFO","message":"database version: PostgreSQL 14.2 on aarch64-apple-darwin20.6.0, compiled by Apple clang version 12.0.5 (clang-1205.0.22.9), 64-bit"}
{"level":"info","time":1654723891,"severity":"INFO","message":"current database user: demo_user"}
{"level":"info","time":1654723891,"severity":"INFO","message":"current database: dga_local"}
{"level":"info","time":1654723891,"severity":"INFO","message":"current search_path: demo"}
{
  "principal": {
    "org": {
      "external_id": "HmiB9CmMpUU8hdVk",
      "name": "Principal",
      "kind_description": "genesis",
      "description": "The Principal org represents the first organization created in the database and exists for the administrative purpose of creating other organizations, apps and users.",
      "create_app_extl_id": "L-qGp1UquEgxKjn2",
      "create_username": "otto.maddox@gmail.com",
      "create_user_first_name": "Otto",
      "create_user_last_name": "Maddox",
      "create_date_time": "2022-06-08T17:31:31-04:00",
      "update_app_extl_id": "L-qGp1UquEgxKjn2",
      "update_username": "otto.maddox@gmail.com",
      "update_user_first_name": "Otto",
      "update_user_last_name": "Maddox",
      "update_date_time": "2022-06-08T17:31:31-04:00"
    },
    "app": {
      "external_id": "L-qGp1UquEgxKjn2",
      "name": "Developer Dashboard",
      "description": "App created as part of Genesis event. To be used solely for creating other apps, orgs and users.",
      "create_app_extl_id": "L-qGp1UquEgxKjn2",
      "create_username": "otto.maddox@gmail.com",
      "create_user_first_name": "Otto",
      "create_user_last_name": "Maddox",
      "create_date_time": "2022-06-08T17:31:31-04:00",
      "update_app_extl_id": "L-qGp1UquEgxKjn2",
      "update_username": "otto.maddox@gmail.com",
      "update_user_first_name": "Otto",
      "update_user_last_name": "Maddox",
      "update_date_time": "2022-06-08T17:31:31-04:00",
      "api_keys": [
        {
          "key": "ZXo3BL-deFqP2VXLIYDAbZzF",
          "deactivation_date": "2099-12-31 00:00:00 +0000 UTC"
        }
      ]
    }
  },
  "test": {
    "org": {
...

When running the Genesis service through mage, the JSON response is sent to the terminal and also ./config/genesis/response.json so you don't need to collect it now.

Briefly, the data model is setup to enable a B2B multi-tenant SAAS, which is overkill for a simple CRUD app, but it's the model I wanted to create/learn and can serve only one tenant just fine. This initial data setup as part of Genesis creates a Principal organization, a Test organization and apps/users within those as well as sets up permissions and roles for access for the user input into the service. The principal org is created solely for the administrative purpose of creating other organizations, apps and users. The test organization is where all tests are run for test data isolation, etc.

Most importantly, a user initiated organization and app is created based on your input in ./config/genesis/cue/input.cue. The response details of this organization (located within the userInitiated node of the response are those which are needed to run the various Movie APIs (create movie, read movie, etc.)


Step 5 - Run Tests

The project tests require that Genesis has been run successfully. If all went well in step 4, you can run the following command to validate:

$ mage -v testall false local
Running target: TestAll
exec: go "test" "./..."
?       github.com/gilcrest/diygoapi  [no test files]
?       github.com/gilcrest/diygoapi/app      [no test files]
?       github.com/gilcrest/diygoapi/audit    [no test files]
ok      github.com/gilcrest/diygoapi/auth     0.331s
ok      github.com/gilcrest/diygoapi/command  0.724s
ok      github.com/gilcrest/diygoapi/datastore        0.682s
?       github.com/gilcrest/diygoapi/datastore/appstore       [no test files]
?       github.com/gilcrest/diygoapi/datastore/authstore      [no test files]
?       github.com/gilcrest/diygoapi/datastore/datastoretest  [no test files]
?       github.com/gilcrest/diygoapi/datastore/moviestore     [no test files]
?       github.com/gilcrest/diygoapi/datastore/orgstore       [no test files]
?       github.com/gilcrest/diygoapi/datastore/personstore    [no test files]
?       github.com/gilcrest/diygoapi/datastore/pingstore      [no test files]
ok      github.com/gilcrest/diygoapi/datastore/userstore      0.742s
ok      github.com/gilcrest/diygoapi/errs     0.490s
?       github.com/gilcrest/diygoapi/gateway  [no test files]
?       github.com/gilcrest/diygoapi/gateway/authgateway      [no test files]
ok      github.com/gilcrest/diygoapi/logger   0.284s
?       github.com/gilcrest/diygoapi/magefiles        [no test files]
ok      github.com/gilcrest/diygoapi/movie    0.571s
?       github.com/gilcrest/diygoapi/org      [no test files]
?       github.com/gilcrest/diygoapi/person   [no test files]
ok      github.com/gilcrest/diygoapi/random   0.333s
?       github.com/gilcrest/diygoapi/random/randomtest        [no test files]
ok      github.com/gilcrest/diygoapi/secure   0.574s
?       github.com/gilcrest/diygoapi/secure/random    [no test files]
ok      github.com/gilcrest/diygoapi/server   0.328s
?       github.com/gilcrest/diygoapi/server/driver    [no test files]
ok      github.com/gilcrest/diygoapi/service  0.504s
ok      github.com/gilcrest/diygoapi/user     0.323s
?       github.com/gilcrest/diygoapi/user/usertest    [no test files]

Note: There are a number of packages without test files, but there is extensive testing as part of this project. More can and will be done, of course...

Step 6 - Run the Web Server

There are three options for running the web server. When running the program, a number of flags can be passed instead of using the environment. The ff library from Peter Bourgon is used to parse the flags. If your preference is to set configuration with environment variables, that is possible as well. Flags take precedence, so if a flag is passed, that will be used. A PostgreSQL database connection is required. If there is no flag set, then the program checks for a matching environment variable. If neither are found, the flag's default value will be used and, depending on the flag, may result in a database connection error.

For simplicity’s sake, the easiest option to start with is setting the environment and running the server with Mage:

Option 1 - Run web server with config file and Mage

You can run the webserver with Mage. As in all examples above, Mage will either use the current environment or set the environment using a config file depending on the environment parameter sent in.

$ mage -v run local
Running target: Run
exec: go "run" "./cmd/diy/main.go"
{"level":"info","time":1675700939,"severity":"INFO","message":"minimum accepted logging level set to trace"}
{"level":"info","time":1675700939,"severity":"INFO","message":"logging level set to debug"}
{"level":"info","time":1675700939,"severity":"INFO","message":"log error stack via github.com/pkg/errors set to false"}
{"level":"info","time":1675700939,"severity":"INFO","message":"sql database opened for localhost on port 5432"}
{"level":"info","time":1675700939,"severity":"INFO","message":"sql database Ping returned successfully"}
{"level":"info","time":1675700939,"severity":"INFO","message":"database version: PostgreSQL 14.6 on aarch64-apple-darwin20.6.0, compiled by Apple clang version 12.0.5 (clang-1205.0.22.9), 64-bit"}
{"level":"info","time":1675700939,"severity":"INFO","message":"current database user: demo_user"}
{"level":"info","time":1675700939,"severity":"INFO","message":"current database: dga_local"}
{"level":"info","time":1675700939,"severity":"INFO","message":"current search_path: demo"}
Option 2 - Run web server using command line flags

The below are the list of the command line flags that can be used to start the webserver (and their equivalent environment variable name for reference as well):

Flag Name Description Environment Variable Default
port Port the server will listen on PORT 8080
log-level zerolog logging level (debug, info, etc.) LOG_LEVEL debug
log-level-min sets the minimum accepted logging level LOG_LEVEL_MIN debug
log-error-stack If true, log error stacktrace using github.com/pkg/errors, else just log error (includes op stack) LOG_ERROR_STACK false
db-host The host name of the database server. DB_HOST
db-port The port number the database server is listening on. DB_PORT 5432
db-name The database name. DB_NAME
db-user PostgreSQL™ user name to connect as. DB_USER
db-password Password to be used if the server demands password authentication. DB_PASSWORD
db-search-path Schema search path to be used when connecting. DB_SEARCH_PATH
encrypt-key Encryption key to be used for all encrypted data. ENCRYPT_KEY

Starting the web server with command line flags looks like:

$ go run main.go -db-name=dga_local -db-user=demo_user -db-password=REPLACE_ME -db-search-path=demo -encrypt-key=31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06
{"level":"info","time":1656296193,"severity":"INFO","message":"minimum accepted logging level set to trace"}
{"level":"info","time":1656296193,"severity":"INFO","message":"logging level set to debug"}
{"level":"info","time":1656296193,"severity":"INFO","message":"log error stack global set to true"}
{"level":"info","time":1656296193,"severity":"INFO","message":"sql database opened for localhost on port 5432"}
{"level":"info","time":1656296193,"severity":"INFO","message":"sql database Ping returned successfully"}
{"level":"info","time":1656296193,"severity":"INFO","message":"database version: PostgreSQL 14.4 on aarch64-apple-darwin20.6.0, compiled by Apple clang version 12.0.5 (clang-1205.0.22.9), 64-bit"}
{"level":"info","time":1656296193,"severity":"INFO","message":"current database user: demo_user"}
{"level":"info","time":1656296193,"severity":"INFO","message":"current database: dga_local"}
{"level":"info","time":1656296193,"severity":"INFO","message":"current search_path: demo"}
Option 3 - Run web server using independently set environment

If you're not using mage or command line flags and have set the appropriate environment variables properly, you can run the web server simply like so:

$ go run main.go
{"level":"info","time":1656296765,"severity":"INFO","message":"minimum accepted logging level set to trace"}
{"level":"info","time":1656296765,"severity":"INFO","message":"logging level set to debug"}
{"level":"info","time":1656296765,"severity":"INFO","message":"log error stack global set to true"}
{"level":"info","time":1656296765,"severity":"INFO","message":"sql database opened for localhost on port 5432"}
{"level":"info","time":1656296765,"severity":"INFO","message":"sql database Ping returned successfully"}
{"level":"info","time":1656296765,"severity":"INFO","message":"database version: PostgreSQL 14.4 on aarch64-apple-darwin20.6.0, compiled by Apple clang version 12.0.5 (clang-1205.0.22.9), 64-bit"}
{"level":"info","time":1656296765,"severity":"INFO","message":"current database user: gilcrest"}
{"level":"info","time":1656296765,"severity":"INFO","message":"current database: dga_local"}
{"level":"info","time":1656296765,"severity":"INFO","message":"current search_path: demo"}
Step 7 - Send Requests
cURL Commands to Call Ping Service

With the server up and running, the easiest service to interact with is the ping service. This service is a simple health check that returns a series of flags denoting health of the system (queue depths, database up boolean, etc.). For right now, the only thing it checks is if the database is up and pingable. I have left this service unauthenticated so there's at least one service that you can get to without having to have an authentication token, but in actuality, I would typically have every service behind a security token.

Use cURL GET request to call ping:

$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/ping'
{"db_up":true}
cURL Commands to Call Movie Services

The values for the x-app-id and x-api-key headers needed for all below services are found in the /api/v1/genesis service response. If you used mage to run the service on your local machine, the response can be found at ./config/genesis/response.json:

  • APP ID (x-app-id): userInitiated.app.external_id
  • API Key (x-api-key): userInitiated.app.api_keys[0].key

The Bearer token for the Authorization header needs to be generated through Google's OAuth2 mechanism. Assuming you've completed setup mentioned in Step 2, you can generate a new token at the Google OAuth2 Playground

Create Movie - use the POST HTTP verb at /api/v1/movies:

$ curl --location --request POST 'http://127.0.0.1:8080/api/v1/movies' \
--header 'Content-Type: application/json' \
--header 'x-app-id: <REPLACE WITH APP ID>' \
--header 'x-api-key: <REPLACE WITH API KEY>' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \
--data-raw '{
    "title": "Repo Man",
    "rated": "R",
    "release_date": "1984-03-02T00:00:00Z",
    "run_time": 92,
    "director": "Alex Cox",
    "writer": "Alex Cox"
}'
{"external_id":"IUAtsOQuLTuQA5OM","title":"Repo Man","rated":"R","release_date":"1984-03-02T00:00:00Z","run_time":92,"director":"Alex Cox","writer":"Alex Cox","create_app_extl_id":"nBRyFTHq6PALwMdx","create_username":"dan@dangillis.dev","create_user_first_name":"Otto","create_user_last_name":"Maddox","create_date_time":"2022-06-30T15:26:02-04:00","update_app_extl_id":"nBRyFTHq6PALwMdx","update_username":"dan@dangillis.dev","update_user_first_name":"Otto","update_user_last_name":"Maddox","update_date_time":"2022-06-30T15:26:02-04:00"}

Read (Single Record) - use the GET HTTP verb at /api/v1/movies/:extl_id with the movie external_id from the create (POST) response as the unique identifier in the URL. I try to never expose primary keys, so I use something like an external id as an alternative key.

$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/movies/IUAtsOQuLTuQA5OM' \
--header 'x-app-id: <REPLACE WITH APP ID>' \
--header 'x-api-key: <REPLACE WITH API KEY>' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \
{"external_id":"IUAtsOQuLTuQA5OM","title":"Repo Man","rated":"R","release_date":"1984-03-02T00:00:00Z","run_time":92,"director":"Alex Cox","writer":"Alex Cox","create_app_extl_id":"QfLDvkZlAEieAA7u","create_username":"dan@dangillis.dev","create_user_first_name":"Otto","create_user_last_name":"Maddox","create_date_time":"2022-06-30T15:26:02-04:00","update_app_extl_id":"QfLDvkZlAEieAA7u","update_username":"dan@dangillis.dev","update_user_first_name":"Otto","update_user_last_name":"Maddox","update_date_time":"2022-06-30T15:26:02-04:00"}

Read (All Records) - use the GET HTTP verb at /api/v1/movies:

$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/movies' \
--header 'x-app-id: <REPLACE WITH APP ID>' \
--header 'x-api-key: <REPLACE WITH API KEY>' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \

Update - use the PUT HTTP verb at /api/v1/movies/:extl_id with the movie external_id from the create (POST) response as the unique identifier in the URL.

$ curl --location --request PUT 'http://127.0.0.1:8080/api/v1/movies/IUAtsOQuLTuQA5OM' \
--header 'Content-Type: application/json' \
--header 'x-app-id: <REPLACE WITH APP ID>' \
--header 'x-api-key: <REPLACE WITH API KEY>' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \
--data-raw '{
    "title": "Repo Man",
    "rated": "R",
    "release_date": "1984-03-02T00:00:00Z",
    "run_time": 91,
    "director": "Alex Cox",
    "writer": "Alex Cox"
}'
{"external_id":"IUAtsOQuLTuQA5OM","title":"Repo Man","rated":"R","release_date":"1984-03-02T00:00:00Z","run_time":91,"director":"Alex Cox","writer":"Alex Cox","create_app_extl_id":"QfLDvkZlAEieAA7u","create_username":"dan@dangillis.dev","create_user_first_name":"Otto","create_user_last_name":"Maddox","create_date_time":"2022-06-30T15:26:02-04:00","update_app_extl_id":"nBRyFTHq6PALwMdx","update_username":"dan@dangillis.dev","update_user_first_name":"Otto","update_user_last_name":"Maddox","update_date_time":"2022-06-30T15:38:42-04:00"}

Delete - use the DELETE HTTP verb at /api/v1/movies/:extl_id with the movie external_id from the create (POST) response as the unique identifier in the URL.

$ curl --location --request DELETE 'http://127.0.0.1:8080/api/v1/movies/IUAtsOQuLTuQA5OM' \
--header 'x-app-id: <REPLACE WITH APP ID>' \
--header 'x-api-key: <REPLACE WITH API KEY>' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \
{"extl_id":"IUAtsOQuLTuQA5OM","deleted":true}

Project Walkthrough

Package Layout

RealWorld Example Applications

The above image is a high-level view of an example request that is processed by the server (creating a movie). To summarize, after receiving an http request, the request path, method, etc. is matched to a registered route in the gorilla mux router (router initialization is part of server startup in the command package) as part of the routes.go file in the server package. The request is then sent through a sequence of middleware handlers for setting up request logging, response headers, authentication and authorization. Finally, the request is routed through a bespoke app handler, in this case handleMovieCreate.

diygoapi package layout is based on several projects, but the primary source of inspiration is the WTF Dial app repo and accompanying blog from Ben Johnson. It's really a wonderful resource and I encourage everyone to read it.

Errors

Handling errors is really important in Go. Errors are first class citizens and there are many different approaches for handling them. I have based my error handling on a blog post from Rob Pike and have modified it to meet my needs. The post is many years old now, but I find the lessons there still hold true at least for my requirements.

Error Requirements

My requirements for REST API error handling are the following:

  • Requests for users who are not properly authenticated should return a 401 Unauthorized error with a WWW-Authenticate response header and an empty response body.
  • Requests for users who are authenticated, but do not have permission to access the resource, should return a 403 Forbidden error with an empty response body.
  • All requests which are due to a client error (invalid data, malformed JSON, etc.) should return a 400 Bad Request and a response body which looks similar to the following:
{
    "error": {
        "kind": "input_validation_error",
        "param": "director",
        "message": "director is required"
    }
}
  • All requests which incur errors as a result of an internal server or database error should return a 500 Internal Server Error and not leak any information about the database or internal systems to the client. These errors should return a response body which looks like the following:
{
    "error": {
        "kind": "internal_error",
        "message": "internal server error - please contact support"
    }
}

All errors should return a Request-Id response header with a unique request id that can be used for debugging to find the corresponding error in logs.

Error Implementation

All errors should be raised using custom errors from the domain/errs package. The three custom errors correspond directly to the requirements above.

Typical Errors

Typical errors raised throughout diygoapi are the custom errs.Error, which look like:

// Error is the type that implements the error interface.
// It contains a number of fields, each of different type.
// An Error value may leave some values unset.
type Error struct {
   // Op is the operation being performed, usually the name of the method
   // being invoked.
   Op Op
   // User is the name of the user attempting the operation.
   User UserName
   // Kind is the class of error, such as permission failure,
   // or "Other" if its class is unknown or irrelevant.
   Kind Kind
   // Param represents the parameter related to the error.
   Param Parameter
   // Code is a human-readable, short representation of the error
   Code Code
   // Realm is a description of a protected area, used in the WWW-Authenticate header.
   Realm Realm
   // The underlying error that triggered this one, if any.
   Err error
}

This custom error type is raised using the E function from the domain/errs package. errs.E is taken from Rob Pike's upspin errors package (but has been changed based on my requirements). The errs.E function call is variadic and can take several different types to form the custom errs.Error struct.

Here is a simple example of creating an error using errs.E:

err := errs.E("seems we have an error here")

When a string is sent, a new error will be created and added to the Err element of the struct. In the above example, Op, User, Kind, Param, Realm and Code would all remain unset.

By convention, we create an op constant to denote the method or function where the error is occuring (or being returned through). This op constant should always be the first argument in each call, though it is not actually required to be.

package opdemo

import (
    "fmt"

    "github.com/gilcrest/diygoapi/errs"
)

// IsEven returns an error if the number given is not even
func IsEven(n int) error {
    const op errs.Op = "opdemo/IsEven"

    if n%2 != 0 {
        return errs.E(op, fmt.Sprintf("%d is not even", n))
    }
    return nil
}

You can set any of these custom errs.Error fields that you like, for example:

var released time.Time
released, err = time.Parse(time.RFC3339, r.Released)
if err != nil {
    return nil, errs.E(op, errs.Validation,
        errs.Code("invalid_date_format"),
        errs.Parameter("release_date"),
        err)
}

Above, we used errs.Validation to set the errs.Kind as Validation. Valid error Kind are:

const (
    Other          Kind = iota // Unclassified error. This value is not printed in the error message.
    Invalid                    // Invalid operation for this type of item.
    IO                         // External I/O error such as network failure.
    Exist                      // Item already exists.
    NotExist                   // Item does not exist.
    Private                    // Information withheld.
    Internal                   // Internal error or inconsistency.
    BrokenLink                 // Link target does not exist.
    Database                   // Error from database.
    Validation                 // Input validation error.
    Unanticipated              // Unanticipated error.
    InvalidRequest             // Invalid Request
    // Unauthenticated is used when a request lacks valid authentication credentials.
    //
    // For Unauthenticated errors, the response body will be empty.
    // The error is logged and http.StatusUnauthorized (401) is sent.
    Unauthenticated // Unauthenticated Request
    // Unauthorized is used when a user is authenticated, but is not authorized
    // to access the resource.
    //
    // For Unauthorized errors, the response body should be empty.
    // The error is logged and http.StatusForbidden (403) is sent.
    Unauthorized
)

errs.Code represents a short code to respond to the client with for error handling based on codes (if you choose to do this) and is any string you want to pass.

errs.Parameter represents the parameter that is being validated or has problems, etc.

Note in the above example, instead of passing a string and creating a new error inside the errs.E function, I am directly passing the error returned by the time.Parse function to errs.E. The error is then added to the Err field using errors.WithStack from the github.com/pkg/errors package, which enables stacktrace retrieval later.

There are a few helpers in the errs package as well, namely the errs.MissingField function which can be used when validating missing input on a field. This idea comes from this Mat Ryer post and is pretty handy.

Here is an example in practice:

// IsValid performs validation of the struct
func (m *Movie) IsValid() error {
    const op errs.Op = "diygoapi/Movie.IsValid"

    switch {
    case m.Title == "":
        return errs.E(op, errs.Validation, errs.Parameter("title"), errs.MissingField("title"))

The error message for the above would read title is required

There is also errs.InputUnwanted which is meant to be used when a field is populated with a value when it is not supposed to be.

Typical Error Flow

As errors created with errs.E move up the call stack, the op can just be added to the error, like the following:

func outer() error {
    const op errs.Op = "opdemo/outer"

    err := middle()
    if err != nil {
        return errs.E(op, err)
    }
    return nil
}

func middle() error {
    err := inner()
    if err != nil {
        return errs.E(errs.Op("opdemo/middle"), err)
    }
    return nil
}

func inner() error {
    const op errs.Op = "opdemo/inner"

    return errs.E(op, "seems we have an error here")
}

Note that errs.Op can be created inline as part of the error instead of creating a constant as done in the middle function, I just prefer to create the constant in most cases.

In addition, you can add context fields (errs.Code, errs.Parameter, errs.Kind) as the error moves up the stack, however, I try to add as much context as possible at the point of error origin and only do this in rare cases.

Handler Flow

At the top of the program flow for each route is the handler (for example, Server.handleMovieCreate). In this handler, any error returned from any function or method is sent through the errs.HTTPErrorResponse function along with the http.ResponseWriter and a zerolog.Logger.

For example:

response, err := s.CreateMovieService.Create(r.Context(), rb, u)
if err != nil {
    errs.HTTPErrorResponse(w, logger, err)
    return
}

errs.HTTPErrorResponse takes the custom errs.Error type and writes the response to the given http.ResponseWriter and logs the error using the given zerolog.Logger.

return must be called immediately after errs.HTTPErrorResponse to return the error to the client.

Typical Error Response

If an errs.Error type is sent to errs.HTTPErrorResponse, the function writes the HTTP response body as JSON using the errs.ErrResponse struct.

// ErrResponse is used as the Response Body
type ErrResponse struct {
    Error ServiceError `json:"error"`
}

// ServiceError has fields for Service errors. All fields with no data will be omitted
type ServiceError struct {
    Kind    string `json:"kind,omitempty"`
    Code    string `json:"code,omitempty"`
    Param   string `json:"param,omitempty"`
    Message string `json:"message,omitempty"`
}

When the error is returned to the client, the response body JSON looks like the following:

{
    "error": {
        "kind": "input validation error",
        "param": "title",
        "message": "title is required"
    }
}

In addition, the error is logged. By default, the error stack is built using the op context added to errors and added to the log as a string array in the stack field (see below). For the majority of cases, I believe this is sufficient.

{
   "level": "error",
   "remote_ip": "127.0.0.1:60382",
   "user_agent": "PostmanRuntime/7.30.1",
   "request_id": "cfgihljuns2hhjb77tq0",
   "stack": [
      "diygoapi/Movie.IsValid",
      "service/MovieService.Create"
   ],
   "error": "title is required",
   "http_statuscode": 400,
   "Kind": "input validation error",
   "Parameter": "title",
   "Code": "",
   "time": 1675700438,
   "severity": "ERROR",
   "message": "error response sent to client"
}

If you feel you need the full error stack trace, you can set the flag, environment variable on startup or call the PUT method for the {{base_url}}/api/v1/logger service to update zerolog.ErrorStackMarshaler and set it to log error stacks (more about this below). The logger will log the full error stack, which can be super helpful when trying to identify issues.

The error log will look like the following (I cut off parts of the stack for brevity):

{
    "level": "error",
    "ip": "127.0.0.1",
    "user_agent": "PostmanRuntime/7.26.8",
    "request_id": "bvol0mtnf4q269hl3ra0",
    "stack": [{
        "func": "E",
        "line": "172",
        "source": "errs.go"
    }, {
        "func": "(*Movie).SetReleased",
        "line": "76",
        "source": "movie.go"
    }, {
        "func": "(*MovieController).CreateMovie",
        "line": "139",
        "source": "create.go"
    }, {
    ...
    }],
    "error": "parsing time \"1984a-03-02T00:00:00Z\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"a-03-02T00:00:00Z\" as \"-\"",
    "HTTPStatusCode": 400,
    "Kind": "input_validation_error",
    "Parameter": "release_date",
    "Code": "invalid_date_format",
    "time": 1609650267,
    "severity": "ERROR",
    "message": "Response Error Sent"
}

Note: E will usually be at the top of the stack as it is where the errors.New or errors.WithStack functions are being called.

Internal or Database Error Response

There is logic within errs.HTTPErrorResponse to return a different response body if the errs.Kind is Internal or Database. As per the requirements, we should not leak the error message or any internal stack, etc. when an internal or database error occurs. If an error comes through and is an errs.Error with either of these error Kind or is an unknown error type in any way, the response will look like the following:

{
    "error": {
        "kind": "internal_error",
        "message": "internal server error - please contact support"
    }
}

Unauthenticated Errors

The spec for 401 Unauthorized calls for a WWW-Authenticate response header along with a realm. The realm should be set when creating an Unauthenticated error.

Unauthenticated Error Flow

Unauthenticated errors should only be raised at points of authentication as part of a middleware handler. I will get into application flow in detail later, but authentication for diygoapi happens in middleware handlers prior to calling the final app handler for the given route.

The example below demonstrates returning an Unauthenticated error if the Authorization header is not present. This is done using the errs.E function (common to all errors in this repo), but the errs.Kind is sent as errs.Unauthenticated. An errs.Realm type should be added as well. For now, the constant defaultRealm is set to diygoapi in the server package and is used for all unauthenticated errors. You can set this constant to whatever value you like for your application.

// parseAuthorizationHeader parses/validates the Authorization header and returns an Oauth2 token
func parseAuthorizationHeader(realm string, header http.Header) (*oauth2.Token, error) {
    const op errs.Op = "server/parseAuthorizationHeader"

    // Pull the token from the Authorization header by retrieving the
    // value from the Header map with "Authorization" as the key
    //
    // format: Authorization: Bearer
    headerValue, ok := header["Authorization"]
    if !ok {
        return nil, errs.E(op, errs.Unauthenticated, errs.Realm(realm), "unauthenticated: no Authorization header sent")
    }
...
Unauthenticated Error Response

Per requirements, diygoapi does not return a response body when returning an Unauthenticated error. The error response from cURL looks like the following:

HTTP/1.1 401 Unauthorized
Request-Id: c30hkvua0brkj8qhk3e0
Www-Authenticate: Bearer realm="diygoapi"
Date: Wed, 09 Jun 2021 19:46:07 GMT
Content-Length: 0

Unauthorized Errors

If the user is not authorized to use the API, an HTTP 403 (Forbidden) response will be sent and the response body will be empty.

Unauthorized Error Flow

Unauthorized errors are raised when there is a permission issue for a user attempting to access a resource. diygoapi currently has a custom database-driven RBAC (Role Based Access Control) authorization implementation (more about this later). The below example demonstrates raising an Unauthorized error and is found in the DBAuthorizer.Authorize method.

return errs.E(errs.Unauthorized, fmt.Sprintf("user %s does not have %s permission for %s", adt.User.Username, r.Method, pathTemplate))

Per requirements, diygoapi does not return a response body when returning an Unauthorized error. The error response from cURL looks like the following:

HTTP/1.1 403 Forbidden
Request-Id: c30hp2ma0brkj8qhk3f0
Date: Wed, 09 Jun 2021 19:54:50 GMT
Content-Length: 0
Logging

diygoapi uses the zerolog library from Olivier Poitrey. The mechanics for using zerolog are straightforward and are well documented in the library's README. zerolog takes an io.Writer as input to create a new logger; for simplicity in diygoapi, I use os.Stdout.

Setting Logger State on Startup

When starting diygoapi, there are several flags which setup the logger:

Flag Name Description Environment Variable Default
log-level zerolog logging level (debug, info, etc.) LOG_LEVEL debug
log-level-min sets the minimum accepted logging level LOG_LEVEL_MIN debug
log-error-stack If true, log error stacktrace using github.com/pkg/errors, else just log error (includes op stack) LOG_ERROR_STACK false

As mentioned above, diygoapi uses the ff library from Peter Bourgon, which allows for using either flags or environment variables. Going forward, we'll assume you've chosen flags.

The log-level flag sets the Global logging level for your zerolog.Logger.

zerolog allows for logging at the following levels (from highest to lowest):

  • panic (zerolog.PanicLevel, 5)
  • fatal (zerolog.FatalLevel, 4)
  • error (zerolog.ErrorLevel, 3)
  • warn (zerolog.WarnLevel, 2)
  • info (zerolog.InfoLevel, 1)
  • debug (zerolog.DebugLevel, 0)
  • trace (zerolog.TraceLevel, -1)

The log-level-min flag sets the minimum accepted logging level, which means, for example, if you set the minimum level to error, the only logs that will be sent to your chosen output will be those that are greater than or equal to error (error, fatal and panic).

The log-error-stack boolean flag tells whether to log full stack traces for each error. If true, the zerolog.ErrorStackMarshaler will be set to pkgerrors.MarshalStack which means, for errors raised using the github.com/pkg/errors package, the error stack trace will be captured and printed along with the log. All errors raised in diygoapi are raised using github.com/pkg/errors if this flag is set to true.

After parsing the command line flags, zerolog.Logger is initialized in main.go

// setup logger with appropriate defaults
lgr := logger.NewLogger(os.Stdout, minlvl, true)

and subsequently injected into the app.Server struct as a Server parameter.

// initialize server configuration parameters
params := app.NewServerParams(lgr, serverDriver)

// initialize Server
s, err := app.NewServer(mr, params)
if err != nil {
    lgr.Fatal().Err(err).Msg("Error from app.NewServer")
}
Logger Setup in Handlers

The Server.routes method is responsible for registering routes and corresponding middleware/handlers to the Server's gorilla/mux router. For each route registered to the handler, upon execution, the initialized zerolog.Logger struct is added to the request context through the Server.loggerChain method.

// register routes/middleware/handlers to the Server router
func (s *Server) routes() {

    // Match only POST requests at /api/v1/movies
    // with Content-Type header = application/json
    s.router.Handle(moviesV1PathRoot,
        s.loggerChain().
            Append(s.appHandler).
            Append(s.authHandler).
            Append(s.authorizeUserHandler).
            Append(s.jsonContentTypeResponseHandler).
            ThenFunc(s.handleMovieCreate)).
        Methods(http.MethodPost).
        Headers(contentTypeHeaderKey, appJSONContentTypeHeaderVal)

...

The Server.loggerChain method sets up the logger with pre-populated fields, including the request method, url, status, size, duration, remote IP, user agent, referer. A unique Request ID is also added to the logger, context and response headers.

func (s *Server) loggerChain() alice.Chain {
    ac := alice.New(hlog.NewHandler(s.logger),
        hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
        hlog.FromRequest(r).Info().
            Str("method", r.Method).
            Stringer("url", r.URL).
            Int("status", status).
            Int("size", size).
            Dur("duration", duration).
            Msg("request logged")
        }),
        hlog.RemoteAddrHandler("remote_ip"),
        hlog.UserAgentHandler("user_agent"),
        hlog.RefererHandler("referer"),
        hlog.RequestIDHandler("request_id", "Request-Id"),
    )

    return ac
}

For every request, you'll get a request log that looks something like the following:

{
   "level": "info",
   "remote_ip": "127.0.0.1:60382",
   "user_agent": "PostmanRuntime/7.30.1",
   "request_id": "cfgihljuns2hhjb77tq0",
   "method": "POST",
   "url": "/api/v1/movies",
   "status": 400,
   "size": 90,
   "duration": 85.747943,
   "time": 1675700438,
   "severity": "INFO",
   "message": "request logged"
}

All error logs will have the same request metadata, including request_id. The Request-Id is also sent back as part of the error response as a response header, allowing you to link the two. An error log will look something like the following:

{
    "level": "error",
    "remote_ip": "127.0.0.1",
    "user_agent": "PostmanRuntime/7.28.0",
    "request_id": "c3nppj6a0brt1dho9e2g",
    "error": "googleapi: Error 401: Request is missing required authentication credential. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project., unauthorized",
    "http_statuscode": 401,
    "realm": "diygoapi",
    "time": 1626315981,
    "severity": "ERROR",
    "message": "Unauthenticated Request"
}

The above error log demonstrates a log for an error with stack trace turned off.

If the Logger is to be used beyond the scope of the handler, it should be pulled from the request context in the handler and sent as a parameter to any inner calls. The Logger is added only to the request context to capture request related fields with the Logger and be able to pass the initialized logger and middleware handlers easier to the app/route handler. Additional use of the logger should be directly called out in function/method signatures so there are no surprises. All logs from the logger passed down get the benefit of the request metadata though, which is great!

Reading and Modifying Logger State

You can retrieve and update the state of these flags using the {{base_url}}/api/v1/logger endpoint.

To retrieve the current logger state use a GET request:

curl --location --request GET 'http://127.0.0.1:8080/api/v1/logger' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>'

and the response will look something like:

{
    "logger_minimum_level": "debug",
    "global_log_level": "error",
    "log_error_stack": false
}

In order to update the logger state use a PUT request:

curl --location --request PUT 'http://127.0.0.1:8080/api/v1/logger' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \
--data-raw '{
    "global_log_level": "debug",
    "log_error_stack": "true"
}'

and the response will look something like:

{
    "logger_minimum_level": "debug",
    "global_log_level": "debug",
    "log_error_stack": true
}

The PUT response is the same as the GET response, but with updated values. In the examples above, I used a scenario where the logger state started with the global logging level (global_log_level) at error and error stack tracing (log_error_stack) set to false. The PUT request then updates the logger state, setting the global logging level to debug and the error stack tracing. You might do something like this if you are debugging an issue and need to see debug logs or error stacks to help with that.

Documentation

Overview

Package diygoapi comprises application or business domain data types and functions.

Index

Constants

View Source
const (
	// AppIDHeaderKey is the App ID header key
	AppIDHeaderKey string = "X-APP-ID"
	// ApiKeyHeaderKey is the API key header key
	ApiKeyHeaderKey string = "X-API-KEY"
	// AuthProviderHeaderKey is the Authorization provider header key
	AuthProviderHeaderKey string = "X-AUTH-PROVIDER"
)
View Source
const BearerTokenType string = "Bearer"

BearerTokenType is used in authorization to access a resource

Variables

This section is empty.

Functions

func NewContextWithApp

func NewContextWithApp(ctx context.Context, a *App) context.Context

NewContextWithApp returns a new context with the given App

func NewContextWithAuthParams

func NewContextWithAuthParams(ctx context.Context, ap *AuthenticationParams) context.Context

NewContextWithAuthParams returns a new context with the given AuthenticationParams

func NewContextWithUser

func NewContextWithUser(ctx context.Context, u *User) context.Context

NewContextWithUser returns a new context with the given User

func NewNullInt32

func NewNullInt32(i int32) sql.NullInt32

NewNullInt32 returns a null if i == 0, otherwise it returns the int32 which was input.

func NewNullInt64

func NewNullInt64(i int64) sql.NullInt64

NewNullInt64 returns a null if i == 0, otherwise it returns the int64 which was input.

func NewNullString

func NewNullString(s string) sql.NullString

NewNullString returns a null if s is empty, otherwise it returns the string which was input

func NewNullTime

func NewNullTime(t time.Time) sql.NullTime

NewNullTime returns a null if t is the zero value for time.Time, otherwise it returns the time which was input

func NewNullUUID

func NewNullUUID(i uuid.UUID) uuid.NullUUID

NewNullUUID returns a null if i == uuid.Nil, otherwise it returns the int32 which was input.

Types

type APIKey

type APIKey struct {
	// contains filtered or unexported fields
}

APIKey is an API key for interacting with the system. The API key string is delivered to the client along with an App ID. The API Key acts as a password for the application.

func NewAPIKey

func NewAPIKey(g APIKeyGenerator, ek *[32]byte, deactivation time.Time) (APIKey, error)

NewAPIKey initializes an APIKey. It generates a random 128-bit (16 byte) base64 encoded string as an API key. The generated key is then encrypted using 256-bit AES-GCM and the encrypted bytes are added to the struct as well.

func NewAPIKeyFromCipher

func NewAPIKeyFromCipher(ciphertext string, ek *[32]byte) (APIKey, error)

NewAPIKeyFromCipher initializes an APIKey given a ciphertext string.

func (*APIKey) Ciphertext

func (a *APIKey) Ciphertext() string

Ciphertext returns the hex encoded text of the encrypted cipher bytes for the API key

func (*APIKey) DeactivationDate

func (a *APIKey) DeactivationDate() time.Time

DeactivationDate returns the Deactivation Date for the API key

func (*APIKey) Key

func (a *APIKey) Key() string

Key returns the key for the API key

func (*APIKey) SetDeactivationDate

func (a *APIKey) SetDeactivationDate(t time.Time)

SetDeactivationDate sets the deactivation date value to AppAPIkey TODO - try SetDeactivationDate as a candidate for generics with 1.18

func (*APIKey) SetStringAsDeactivationDate

func (a *APIKey) SetStringAsDeactivationDate(s string) error

SetStringAsDeactivationDate sets the deactivation date value to AppAPIkey given a string in RFC3339 format

type APIKeyGenerator

type APIKeyGenerator interface {
	RandomString(n int) (string, error)
}

APIKeyGenerator creates a random, 128 API key string

type APIKeyResponse

type APIKeyResponse struct {
	Key              string `json:"key"`
	DeactivationDate string `json:"deactivation_date"`
}

APIKeyResponse is the response fields for an API key

type App

type App struct {
	ID               uuid.UUID
	ExternalID       secure.Identifier
	Org              *Org
	Name             string
	Description      string
	Provider         Provider
	ProviderClientID string
	APIKeys          []APIKey
}

App is an application that interacts with the system

func AppFromContext

func AppFromContext(ctx context.Context) (*App, error)

AppFromContext returns the App from the given context

func AppFromRequest

func AppFromRequest(r *http.Request) (*App, error)

AppFromRequest is a helper function which returns the App from the request context.

func (*App) AddKey

func (a *App) AddKey(key APIKey) error

AddKey validates and adds an API key to the slice of App API keys

func (*App) ValidateKey

func (a *App) ValidateKey(realm, matchKey string) error

ValidateKey determines if the app has a matching key for the input and if that key is valid

type AppResponse

type AppResponse struct {
	ExternalID          string           `json:"external_id"`
	Name                string           `json:"name"`
	Description         string           `json:"description"`
	CreateAppExtlID     string           `json:"create_app_extl_id"`
	CreateUserFirstName string           `json:"create_user_first_name"`
	CreateUserLastName  string           `json:"create_user_last_name"`
	CreateDateTime      string           `json:"create_date_time"`
	UpdateAppExtlID     string           `json:"update_app_extl_id"`
	UpdateUserFirstName string           `json:"update_user_first_name"`
	UpdateUserLastName  string           `json:"update_user_last_name"`
	UpdateDateTime      string           `json:"update_date_time"`
	APIKeys             []APIKeyResponse `json:"api_keys"`
}

AppResponse is the response struct for an App

type AppServicer

type AppServicer interface {
	Create(ctx context.Context, r *CreateAppRequest, adt Audit) (*AppResponse, error)
	Update(ctx context.Context, r *UpdateAppRequest, adt Audit) (*AppResponse, error)
}

AppServicer manages the retrieval and manipulation of an App

type Audit

type Audit struct {
	App    *App
	User   *User
	Moment time.Time
}

Audit represents the moment an App/User interacted with the system.

func AuditFromRequest

func AuditFromRequest(r *http.Request) (adt Audit, err error)

AuditFromRequest is a convenience function that sets up an Audit struct from the App and User set to the request context. The moment is also set to time.Now

type Auth

type Auth struct {
	// ID is the unique identifier for authorization record in database
	ID uuid.UUID

	// User is the unique user associated to the authorization record.
	//
	// A Person can have one or more methods of authentication, however,
	// only one per authorization provider is allowed per User.
	User *User

	// Provider is the authentication provider
	Provider Provider

	// ProviderClientID is the external ID representing the Oauth2 client which
	// authenticated the user.
	ProviderClientID string

	// ProviderPersonID is the authentication provider's unique person/user ID.
	ProviderPersonID string

	// Provider Access Token
	ProviderAccessToken string

	// Provider Access Token Expiration Date/Time
	ProviderAccessTokenExpiry time.Time

	// Provider Refresh Token
	ProviderRefreshToken string
}

Auth represents a user's authorization in the database. It captures the provider Oauth2 credentials. Users are linked to a Person. A single Person could authenticate through multiple providers.

type AuthenticationParams

type AuthenticationParams struct {
	// Realm is a description of a protected area, used in the WWW-Authenticate header.
	Realm string
	// Provider is the authentication provider.
	Provider Provider
	// Token is the authentication token sent as part of Oauth2.
	Token *oauth2.Token
}

AuthenticationParams is the parameters needed for authenticating a User.

func AuthParamsFromContext

func AuthParamsFromContext(ctx context.Context) (*AuthenticationParams, error)

AuthParamsFromContext returns the AuthenticationParams from the given context

type AuthenticationServicer

type AuthenticationServicer interface {

	// SelfRegister is used for first-time registration of a Person/User
	// in the system (associated with an Organization). This is "self
	// registration" as opposed to one person registering another person.
	SelfRegister(ctx context.Context, params *AuthenticationParams) (ur *UserResponse, err error)

	// FindExistingAuth looks up a User given a Provider and Access Token.
	// If a User is not found, an error is returned.
	FindExistingAuth(r *http.Request, realm string) (Auth, error)

	// FindAppByProviderClientID Finds an App given a Provider Client ID as part
	// of an Auth object.
	FindAppByProviderClientID(ctx context.Context, realm string, auth Auth) (a *App, err error)

	// DetermineAppContext checks to see if the request already has an app as part of
	// if it does, use that app as the app for session, if it does not, determine the
	// app based on the user's provider client ID. In either case, return a new context
	// with an app. If there is no app to be found for either, return an error.
	DetermineAppContext(ctx context.Context, auth Auth, realm string) (context.Context, error)

	// FindAppByAPIKey finds an app given its External ID and determines
	// if the given API key is a valid key for it. It is used as part of
	// app authentication.
	FindAppByAPIKey(r *http.Request, realm string) (*App, error)

	// AuthenticationParamExchange returns a ProviderInfo struct
	// after calling remote Oauth2 provider.
	AuthenticationParamExchange(ctx context.Context, params *AuthenticationParams) (*ProviderInfo, error)

	// NewAuthenticationParams parses the provider and authorization
	// headers and returns AuthenticationParams based on the results
	NewAuthenticationParams(r *http.Request, realm string) (*AuthenticationParams, error)
}

AuthenticationServicer represents a service for managing authentication.

For this project, Oauth2 is used for user authentication. It is assumed that the actual user interaction is being orchestrated externally and the server endpoints are being called after an access token has already been retrieved from an authentication provider.

In addition, this project provides for a custom application authentication. If an endpoint request is sent using application credentials, then those will be used. If none are sent, then the client id from the access token must be registered in the system and that is used as the calling application. The latter is likely the more common use case.

type AuthorizationServicer

type AuthorizationServicer interface {
	Authorize(r *http.Request, lgr zerolog.Logger, adt Audit) error
}

AuthorizationServicer represents a service for managing authorization.

type CreateAppRequest

type CreateAppRequest struct {
	Name                   string `json:"name"`
	Description            string `json:"description"`
	Oauth2Provider         string `json:"oauth2_provider"`
	Oauth2ProviderClientID string `json:"oauth2_provider_client_id"`
}

CreateAppRequest is the request struct for Creating an App

func (CreateAppRequest) Validate

func (r CreateAppRequest) Validate() error

Validate determines whether the CreateAppRequest has proper data to be considered valid

type CreateMovieRequest

type CreateMovieRequest struct {
	Title    string `json:"title"`
	Rated    string `json:"rated"`
	Released string `json:"release_date"`
	RunTime  int    `json:"run_time"`
	Director string `json:"director"`
	Writer   string `json:"writer"`
}

CreateMovieRequest is the request struct for Creating a Movie

type CreateOrgRequest

type CreateOrgRequest struct {
	Name             string            `json:"name"`
	Description      string            `json:"description"`
	Kind             string            `json:"kind"`
	CreateAppRequest *CreateAppRequest `json:"app"`
}

CreateOrgRequest is the request struct for Creating an Org

func (CreateOrgRequest) Validate

func (r CreateOrgRequest) Validate() error

Validate determines whether the CreateOrgRequest has proper data to be considered valid

type CreatePermissionRequest

type CreatePermissionRequest struct {
	// A human-readable string which represents a resource (e.g. an HTTP route or document, etc.).
	Resource string `json:"resource"`
	// A string representing the action taken on the resource (e.g. POST, GET, edit, etc.)
	Operation string `json:"operation"`
	// A description of what the permission is granting, e.g. "grants ability to edit a billing document".
	Description string `json:"description"`
	// A boolean denoting whether the permission is active (true) or not (false).
	Active bool `json:"active"`
}

CreatePermissionRequest is the request struct for creating a permission

type CreateRoleRequest

type CreateRoleRequest struct {
	// A human-readable code which represents the role.
	Code string `json:"role_cd"`
	// A longer description of the role.
	Description string `json:"role_description"`
	// A boolean denoting whether the role is active (true) or not (false).
	Active bool `json:"active"`
	// The list of permissions to be given to the role
	Permissions []*FindPermissionRequest
}

CreateRoleRequest is the request struct for creating a role

type DBTX

type DBTX interface {
	Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
	Query(context.Context, string, ...interface{}) (pgx.Rows, error)
	QueryRow(context.Context, string, ...interface{}) pgx.Row
}

DBTX interface mirrors the interface generated by https://github.com/kyleconroy/sqlc to allow passing a Pool or a Tx

type Datastorer

type Datastorer interface {
	// Ping pings the DB pool.
	Ping(ctx context.Context) error
	// BeginTx starts a pgx.Tx using the input context
	BeginTx(ctx context.Context) (pgx.Tx, error)
	// RollbackTx rolls back the input pgx.Tx
	RollbackTx(ctx context.Context, tx pgx.Tx, err error) error
	// CommitTx commits the Tx
	CommitTx(ctx context.Context, tx pgx.Tx) error
}

Datastorer is an interface for working with the Database

type DeleteResponse

type DeleteResponse struct {
	ExternalID string `json:"extl_id"`
	Deleted    bool   `json:"deleted"`
}

DeleteResponse is the general response struct for things which have been deleted

type FindPermissionRequest

type FindPermissionRequest struct {
	// Unique External ID to be given to outside callers.
	ExternalID string `json:"external_id"`
	// A human-readable string which represents a resource (e.g. an HTTP route or document, etc.).
	Resource string `json:"resource"`
	// A string representing the action taken on the resource (e.g. POST, GET, edit, etc.)
	Operation string `json:"operation"`
}

FindPermissionRequest is the response struct for finding a permission

type GenesisRequest

type GenesisRequest struct {
	User struct {
		// Provider: The Oauth2 provider.
		Provider string `json:"provider"`

		// Token: The Oauth2 token to be used to create the user.
		Token string `json:"token"`
	} `json:"user"`

	UserInitiatedOrg CreateOrgRequest `json:"org"`

	// PermissionRequests: The list of permissions to be created as part of Genesis
	CreatePermissionRequests []CreatePermissionRequest `json:"permissions"`

	// CreateRoleRequests: The list of Roles to be created as part of Genesis
	CreateRoleRequests []CreateRoleRequest `json:"roles"`
}

GenesisRequest is the request struct for the genesis service

type GenesisResponse

type GenesisResponse struct {
	Principal     *OrgResponse `json:"principal"`
	Test          *OrgResponse `json:"test"`
	UserInitiated *OrgResponse `json:"userInitiated,omitempty"`
}

GenesisResponse contains both the Genesis response and the Test response

type GenesisServicer

type GenesisServicer interface {
	// Arche creates the initial seed data in the database.
	Arche(ctx context.Context, r *GenesisRequest) (GenesisResponse, error)
	// ReadConfig reads the local config file generated as part of Seed (when run locally).
	// Is only a utility to help with local testing.
	ReadConfig() (GenesisResponse, error)
}

GenesisServicer initializes the database with dependent data

type LoggerRequest

type LoggerRequest struct {
	GlobalLogLevel string `json:"global_log_level"`
	LogErrorStack  string `json:"log_error_stack"`
}

LoggerRequest is the request struct for the app logger

type LoggerResponse

type LoggerResponse struct {
	LoggerMinimumLevel string `json:"logger_minimum_level"`
	GlobalLogLevel     string `json:"global_log_level"`
	LogErrorStack      bool   `json:"log_error_stack"`
}

LoggerResponse is the response struct for the current state of the app logger

type LoggerServicer

type LoggerServicer interface {
	Read() *LoggerResponse
	Update(r *LoggerRequest) (*LoggerResponse, error)
}

LoggerServicer reads and updates the logger state

type Movie

type Movie struct {
	ID         uuid.UUID
	ExternalID secure.Identifier
	Title      string
	Rated      string
	Released   time.Time
	RunTime    int
	Director   string
	Writer     string
}

Movie holds details of a movie

func (*Movie) IsValid

func (m *Movie) IsValid() error

IsValid performs validation of the struct

type MovieResponse

type MovieResponse struct {
	ExternalID          string `json:"external_id"`
	Title               string `json:"title"`
	Rated               string `json:"rated"`
	Released            string `json:"release_date"`
	RunTime             int    `json:"run_time"`
	Director            string `json:"director"`
	Writer              string `json:"writer"`
	CreateAppExtlID     string `json:"create_app_extl_id"`
	CreateUserFirstName string `json:"create_user_first_name"`
	CreateUserLastName  string `json:"create_user_last_name"`
	CreateDateTime      string `json:"create_date_time"`
	UpdateAppExtlID     string `json:"update_app_extl_id"`
	UpdateUserFirstName string `json:"update_user_first_name"`
	UpdateUserLastName  string `json:"update_user_last_name"`
	UpdateDateTime      string `json:"update_date_time"`
}

MovieResponse is the response struct for a Movie

type MovieServicer

type MovieServicer interface {
	Create(ctx context.Context, r *CreateMovieRequest, adt Audit) (*MovieResponse, error)
	Update(ctx context.Context, r *UpdateMovieRequest, adt Audit) (*MovieResponse, error)
	Delete(ctx context.Context, extlID string) (DeleteResponse, error)
	FindMovieByExternalID(ctx context.Context, extlID string) (*MovieResponse, error)
	FindAllMovies(ctx context.Context) ([]*MovieResponse, error)
}

MovieServicer is used to create, read, update and delete movies.

type Org

type Org struct {
	// ID: The unique identifier
	ID uuid.UUID
	// External ID: The unique external identifier
	ExternalID secure.Identifier
	// Name: The organization name
	Name string
	// Description: A longer description of the organization
	Description string
	// Kind: a way of classifying organizations
	Kind *OrgKind
}

Org represents an Organization (company, institution or any other organized body of people with a particular purpose)

func (Org) Validate

func (o Org) Validate() (err error)

Validate determines whether the Org has proper data to be considered valid

type OrgKind

type OrgKind struct {
	// ID: The unique identifier
	ID uuid.UUID
	// External ID: The unique external identifier
	ExternalID string
	// Description: A longer description of the organization kind
	Description string
}

OrgKind is a way of classifying an organization. Examples are Genesis, Test, Standard

func (OrgKind) Validate

func (o OrgKind) Validate() error

Validate determines whether the Person has proper data to be considered valid

type OrgResponse

type OrgResponse struct {
	ExternalID          string       `json:"external_id"`
	Name                string       `json:"name"`
	KindExternalID      string       `json:"kind_description"`
	Description         string       `json:"description"`
	CreateAppExtlID     string       `json:"create_app_extl_id"`
	CreateUserFirstName string       `json:"create_user_first_name"`
	CreateUserLastName  string       `json:"create_user_last_name"`
	CreateDateTime      string       `json:"create_date_time"`
	UpdateAppExtlID     string       `json:"update_app_extl_id"`
	UpdateUserFirstName string       `json:"update_user_first_name"`
	UpdateUserLastName  string       `json:"update_user_last_name"`
	UpdateDateTime      string       `json:"update_date_time"`
	App                 *AppResponse `json:"app,omitempty"`
}

OrgResponse is the response struct for an Org. It contains only one app (even though an org can have many apps). This app is only present in the response when creating an org and accompanying app. I may change this later to be different response structs for different purposes, but for now, this works.

type OrgServicer

type OrgServicer interface {
	// Create manages the creation of an Org (and optional app)
	Create(ctx context.Context, r *CreateOrgRequest, adt Audit) (*OrgResponse, error)
	Update(ctx context.Context, r *UpdateOrgRequest, adt Audit) (*OrgResponse, error)
	Delete(ctx context.Context, extlID string) (DeleteResponse, error)
	FindAll(ctx context.Context) ([]*OrgResponse, error)
	FindByExternalID(ctx context.Context, extlID string) (*OrgResponse, error)
}

OrgServicer manages the retrieval and manipulation of an Org

type Permission

type Permission struct {
	// ID is the unique ID for the Permission.
	ID uuid.UUID
	// ExternalID is the unique External ID to be given to outside callers.
	ExternalID secure.Identifier
	// Resource is a human-readable string which represents a resource (e.g. an HTTP route or document, etc.).
	Resource string
	// Operation represents the action taken on the resource (e.g. POST, GET, edit, etc.)
	Operation string
	// Description is what the permission is granting, e.g. "grants ability to edit a billing document".
	Description string
	// Active is a boolean denoting whether the permission is active (true) or not (false).
	Active bool
}

Permission stores an approval of a mode of access to a resource.

func (Permission) Validate

func (p Permission) Validate() error

Validate determines if the Permission is valid

type PermissionResponse

type PermissionResponse struct {
	// Unique External ID to be given to outside callers.
	ExternalID string `json:"external_id"`
	// A human-readable string which represents a resource (e.g. an HTTP route or document, etc.).
	Resource string `json:"resource"`
	// A string representing the action taken on the resource (e.g. POST, GET, edit, etc.)
	Operation string `json:"operation"`
	// A description of what the permission is granting, e.g. "grants ability to edit a billing document".
	Description string `json:"description"`
	// A boolean denoting whether the permission is active (true) or not (false).
	Active bool `json:"active"`
}

PermissionResponse is the response struct for a permission

type PermissionServicer

type PermissionServicer interface {
	Create(ctx context.Context, r *CreatePermissionRequest, adt Audit) (*PermissionResponse, error)
	FindAll(ctx context.Context) ([]*PermissionResponse, error)
	Delete(ctx context.Context, extlID string) (DeleteResponse, error)
}

PermissionServicer allows for creating, updating, reading and deleting a Permission

type Person

type Person struct {
	// ID: The unique identifier of the Person.
	ID uuid.UUID

	// ExternalID: unique external identifier of the Person
	ExternalID secure.Identifier

	// Users: All the users that are linked to the Person
	// (e.g. a GitHub user, a Google user, etc.).
	Users []*User
}

Person - from Wikipedia: "A person (plural people or persons) is a being that has certain capacities or attributes such as reason, morality, consciousness or self-consciousness, and being a part of a culturally established form of social relations such as kinship, ownership of property, or legal responsibility.

The defining features of personhood and, consequently, what makes a person count as a person, differ widely among cultures and contexts."

A Person can have multiple Users.

func (Person) NullUUID

func (p Person) NullUUID() uuid.NullUUID

NullUUID returns ID as uuid.NullUUID

func (Person) Validate

func (p Person) Validate() (err error)

Validate determines whether the Person has proper data to be considered valid

type PingResponse

type PingResponse struct {
	DBUp bool `json:"db_up"`
}

PingResponse is the response struct for the PingService

type PingServicer

type PingServicer interface {
	Ping(ctx context.Context, lgr zerolog.Logger) PingResponse
}

PingServicer pings the database and responds whether it is up or down

type Provider

type Provider uint8

Provider defines the provider of authorization (Google, Github, Apple, auth0, etc.).

Only Google is used currently.

const (
	UnknownProvider Provider = iota
	Google                   // Google
)

Provider of authorization

The app uses Oauth2 to authorize users with one of the following Providers

func ParseProvider

func ParseProvider(s string) Provider

ParseProvider initializes a Provider given a case-insensitive string

func (Provider) String

func (p Provider) String() string

type ProviderInfo

type ProviderInfo struct {
	Provider  Provider
	TokenInfo *ProviderTokenInfo
	UserInfo  *ProviderUserInfo
}

ProviderInfo contains information returned from an authorization provider

type ProviderTokenInfo

type ProviderTokenInfo struct {

	// Token is the Oauth2 token. For inbound requests, only the
	// Access Token is given in the Authorization header, so the
	// other details (Refresh Token, Token Type, Expiry) must be
	// retrieved from a 3rd party service. The token's Expiry is
	// a calculated time of expiration (estimated). This is a moving
	// target as some providers send the actual time of expiration,
	// others just send seconds until expiration, which means it's
	// a calculation and won't have perfect precision.
	Token *oauth2.Token

	// Client ID: External ID representing the Oauth2 client which
	// authenticated the user.
	ClientID string

	// Scope: The space separated list of scopes granted to this token.
	Scope string

	// Audience: Who is the intended audience for this token. In general the
	// same as issued_to.
	Audience string `json:"audience,omitempty"`

	// IssuedTo: To whom was the token issued to. In general the same as
	// audience.
	IssuedTo string `json:"issued_to,omitempty"`
}

ProviderTokenInfo contains non-user information gleaned from the Oauth2 provider's access token and subsequent calls to get information about a person using it. See ProviderUserInfo for user information.

type ProviderUserInfo

type ProviderUserInfo struct {
	// ID: The obfuscated ID of the user assigned by the authentication provider.
	ExternalID string

	// Email: The user's email address.
	Email string

	// VerifiedEmail: Boolean flag which is true if the email address is
	// verified. Present only if the email scope is present in the request.
	VerifiedEmail bool

	// NamePrefix: The name prefix for the Profile (e.g. Mx., Ms., Mr., etc.)
	NamePrefix string

	// MiddleName: The person's middle name.
	MiddleName string

	// FirstName: The user's first name.
	FirstName string

	// FamilyName: The user's last name.
	LastName string

	// FullName: The user's full name.
	FullName string

	// NameSuffix: The name suffix for the person's name (e.g. "PhD", "CCNA", "OBE").
	// Other examples include generational designations like "Sr." and "Jr." and "I", "II", "III", etc.
	NameSuffix string

	// Nickname: The person's nickname
	Nickname string

	// Gender: The user's gender. TODO - setup Gender properly. not binary.
	Gender string

	// BirthDate: The full birthdate of a person (e.g. Dec 18, 1953)
	BirthDate time.Time

	// Hd: The hosted domain e.g. example.com if the user is Google apps
	// user.
	HostedDomain string

	// Link: URL of the profile page.
	ProfileLink string

	// Locale: The user's preferred locale.
	Locale string

	// Picture: URL of the user's picture image.
	Picture string
}

ProviderUserInfo contains common fields from the various Oauth2 providers. Currently only using Google, so looks a lot like Google's.

type RegisterUserServicer

type RegisterUserServicer interface {
	SelfRegister(ctx context.Context, adt Audit) error
}

RegisterUserServicer registers a new user

type Role

type Role struct {
	// The unique ID for the Role.
	ID uuid.UUID
	// Unique External ID to be given to outside callers.
	ExternalID secure.Identifier
	// A human-readable code which represents the role.
	Code string
	// A longer description of the role.
	Description string
	// A boolean denoting whether the role is active (true) or not (false).
	Active bool
	// Permissions is the list of permissions allowed for the role.
	Permissions []*Permission
}

Role is a job function or title which defines an authority level.

func (Role) Validate

func (r Role) Validate() error

Validate determines if the Role is valid.

type RoleResponse

type RoleResponse struct {
	// Unique External ID to be given to outside callers.
	ExternalID string `json:"external_id"`
	// A human-readable code which represents the role.
	Code string `json:"role_cd"`
	// A longer description of the role.
	Description string `json:"role_description"`
	// A boolean denoting whether the role is active (true) or not (false).
	Active bool `json:"active"`
	// Permissions is the list of permissions allowed for the role.
	Permissions []*Permission
}

RoleResponse is the response struct for a Role.

type RoleServicer

type RoleServicer interface {
	Create(ctx context.Context, r *CreateRoleRequest, adt Audit) (*RoleResponse, error)
}

RoleServicer allows for creating, updating, reading and deleting a Role as well as assigning permissions and users to it.

type SimpleAudit

type SimpleAudit struct {
	Create Audit `json:"create"`
	Update Audit `json:"update"`
}

SimpleAudit captures the first time a record was written as well as the last time the record was updated. The first time a record is written Create and Update will be identical.

type TokenExchanger

type TokenExchanger interface {
	Exchange(ctx context.Context, realm string, provider Provider, token *oauth2.Token) (*ProviderInfo, error)
}

TokenExchanger exchanges an oauth2.Token for a ProviderUserInfo struct populated with information retrieved from an authentication provider.

type UpdateAppRequest

type UpdateAppRequest struct {
	ExternalID  string
	Name        string `json:"name"`
	Description string `json:"description"`
}

UpdateAppRequest is the request struct for Updating an App

type UpdateMovieRequest

type UpdateMovieRequest struct {
	ExternalID string
	Title      string `json:"title"`
	Rated      string `json:"rated"`
	Released   string `json:"release_date"`
	RunTime    int    `json:"run_time"`
	Director   string `json:"director"`
	Writer     string `json:"writer"`
}

UpdateMovieRequest is the request struct for updating a Movie

type UpdateOrgRequest

type UpdateOrgRequest struct {
	ExternalID  string
	Name        string `json:"name"`
	Description string `json:"description"`
}

UpdateOrgRequest is the request struct for Updating an Org

type User

type User struct {
	// ID: The unique identifier for the Person's profile
	ID uuid.UUID

	// ExternalID: unique external identifier of the User
	ExternalID secure.Identifier

	// NamePrefix: The name prefix for the Profile (e.g. Mx., Ms., Mr., etc.)
	NamePrefix string

	// FirstName: The person's first name.
	FirstName string

	// MiddleName: The person's middle name.
	MiddleName string

	// LastName: The person's last name.
	LastName string

	// FullName: The person's full name.
	FullName string

	// NameSuffix: The name suffix for the person's name (e.g. "PhD", "CCNA", "OBE").
	// Other examples include generational designations like "Sr." and "Jr." and "I", "II", "III", etc.
	NameSuffix string

	// Nickname: The person's nickname
	Nickname string

	// Gender: The user's gender. TODO - setup Gender properly. not binary.
	Gender string

	// Email: The primary email for the User
	Email string

	// CompanyName: The Company Name that the person works at
	CompanyName string

	// CompanyDepartment: is the department at the company that the person works at
	CompanyDepartment string

	// JobTitle: The person's Job Title
	JobTitle string

	// BirthDate: The full birthdate of a person (e.g. Dec 18, 1953)
	BirthDate time.Time

	// LanguagePreferences is the user's language tag preferences.
	LanguagePreferences []language.Tag

	// HostedDomain: The hosted domain e.g. example.com.
	HostedDomain string

	// PictureURL: URL of the person's picture image for the profile.
	PictureURL string

	// ProfileLink: URL of the profile page.
	ProfileLink string

	// Source: The origin of the User (e.g. Google Oauth2, Apple Oauth2, etc.)
	Source string
}

User - from Wikipedia: "A user is a person who utilizes a computer or network service." In the context of this project, given that we allow Persons to authenticate with multiple providers, a User is akin to a persona (Wikipedia - "The word persona derives from Latin, where it originally referred to a theatrical mask. On the social web, users develop virtual personas as online identities.") and as such, a Person can have one or many Users (for instance, I can have a GitHub user and a Google user, but I am just one Person).

As a general, practical matter, most operations are considered at the User level. For instance, roles are assigned at the user level instead of the Person level, which allows for more fine-grained access control.

func NewUserFromProviderInfo added in v0.52.0

func NewUserFromProviderInfo(pi *ProviderInfo, lm language.Matcher) *User

NewUserFromProviderInfo creates a new User struct to be used in db user creation

func UserFromContext added in v0.52.0

func UserFromContext(ctx context.Context) (*User, error)

UserFromContext returns the User from the given Context

func UserFromRequest

func UserFromRequest(r *http.Request) (u *User, err error)

UserFromRequest returns the User from the request context

func (User) NullUUID

func (u User) NullUUID() uuid.NullUUID

NullUUID returns ID as uuid.NullUUID

func (User) Validate

func (u User) Validate() error

Validate determines whether the Person has proper data to be considered valid

type UserResponse added in v0.52.0

type UserResponse struct {
	// ID: The unique identifier for the Person's profile
	ID uuid.UUID

	// ExternalID: unique external identifier of the User
	ExternalID secure.Identifier `json:"external_id"`

	// NamePrefix: The name prefix for the Profile (e.g. Mx., Ms., Mr., etc.)
	NamePrefix string `json:"name_prefix"`

	// FirstName: The person's first name.
	FirstName string `json:"first_name"`

	// MiddleName: The person's middle name.
	MiddleName string `json:"middle_name"`

	// LastName: The person's last name.
	LastName string `json:"last_name"`

	// FullName: The person's full name.
	FullName string `json:"full_name"`

	// NameSuffix: The name suffix for the person's name (e.g. "PhD", "CCNA", "OBE").
	// Other examples include generational designations like "Sr." and "Jr." and "I", "II", "III", etc.
	NameSuffix string `json:"name_suffix"`

	// Nickname: The person's nickname
	Nickname string `json:"nickname"`

	// Email: The primary email for the User
	Email string `json:"email"`

	// CompanyName: The Company Name that the person works at
	CompanyName string `json:"company_name"`

	// CompanyDepartment: is the department at the company that the person works at
	CompanyDepartment string `json:"company_department"`

	// JobTitle: The person's Job Title
	JobTitle string `json:"job_title"`

	// BirthDate: The full birthdate of a person (e.g. Dec 18, 1953)
	BirthDate time.Time `json:"birth_date"`

	// LanguagePreferences is the user's language tag preferences.
	LanguagePreferences []language.Tag `json:"language_preferences"`

	// HostedDomain: The hosted domain e.g. example.com.
	HostedDomain string `json:"hosted_domain"`

	// PictureURL: URL of the person's picture image for the profile.
	PictureURL string `json:"picture_url"`

	// ProfileLink: URL of the profile page.
	ProfileLink string `json:"profile_link"`

	// Source: The origin of the User (e.g. Google Oauth2, Apple Oauth2, etc.)
	Source string `json:"source"`
}

UserResponse - from Wikipedia: "A user is a person who utilizes a computer or network service." In the context of this project, given that we allow Persons to authenticate with multiple providers, a User is akin to a persona (Wikipedia - "The word persona derives from Latin, where it originally referred to a theatrical mask. On the social web, users develop virtual personas as online identities.") and as such, a Person can have one or many Users (for instance, I can have a GitHub user and a Google user, but I am just one Person).

As a general, practical matter, most operations are considered at the User level. For instance, roles are assigned at the user level instead of the Person level, which allows for more fine-grained access control.

Directories

Path Synopsis
cmd
diy
Package errs is a modified copy of the upspin.io/errors package.
Package errs is a modified copy of the upspin.io/errors package.
Package gateway and packages within provide abstractions for interacting with external systems or resources
Package gateway and packages within provide abstractions for interacting with external systems or resources
Package logger has helpers to setup a zerolog.Logger
Package logger has helpers to setup a zerolog.Logger
Package server provides a preconfigured HTTP server.
Package server provides a preconfigured HTTP server.
driver
Package driver defines an interface for custom HTTP listeners.
Package driver defines an interface for custom HTTP listeners.
Package service orchestrates components between handlers and other packages (datastore, gateway, domain, etc.)
Package service orchestrates components between handlers and other packages (datastore, gateway, domain, etc.)
Package sqldb is used to interact with a datastore.
Package sqldb is used to interact with a datastore.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL