Repository: gilcrest/diygoapi
Branch: main
Commit: 7a4aacf5c3ee
Files: 51
Total size: 174.5 KB
Directory structure:
gitextract_odti4dkr/
├── .claude/
│ └── skills/
│ └── db-init-config/
│ └── SKILL.md
├── .git/
│ ├── HEAD
│ ├── config
│ ├── description
│ ├── hooks/
│ │ ├── applypatch-msg.sample
│ │ ├── commit-msg.sample
│ │ ├── fsmonitor-watchman.sample
│ │ ├── post-update.sample
│ │ ├── pre-applypatch.sample
│ │ ├── pre-commit.sample
│ │ ├── pre-merge-commit.sample
│ │ ├── pre-push.sample
│ │ ├── pre-rebase.sample
│ │ ├── pre-receive.sample
│ │ ├── prepare-commit-msg.sample
│ │ ├── push-to-checkout.sample
│ │ ├── sendemail-validate.sample
│ │ └── update.sample
│ ├── index
│ ├── info/
│ │ └── exclude
│ ├── logs/
│ │ ├── HEAD
│ │ └── refs/
│ │ ├── heads/
│ │ │ └── main
│ │ └── remotes/
│ │ └── origin/
│ │ └── HEAD
│ ├── objects/
│ │ └── pack/
│ │ ├── pack-4cf8052845b33e6cfeabee017e720515ff38889e.idx
│ │ ├── pack-4cf8052845b33e6cfeabee017e720515ff38889e.pack
│ │ ├── pack-4cf8052845b33e6cfeabee017e720515ff38889e.promisor
│ │ └── pack-4cf8052845b33e6cfeabee017e720515ff38889e.rev
│ ├── packed-refs
│ ├── refs/
│ │ ├── heads/
│ │ │ └── main
│ │ └── remotes/
│ │ └── origin/
│ │ └── HEAD
│ └── shallow
├── .github/
│ └── dependabot.yml
├── .gitignore
├── CLAUDE.md
├── README.md
├── Taskfile.yml
├── app.go
├── app_test.go
├── auth.go
├── auth_test.go
├── context.go
├── context_test.go
├── db.go
├── diygoapi.go
├── go.mod
├── go.sum
├── movie.go
├── movie_test.go
├── org.go
├── user.go
└── user_test.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .claude/skills/db-init-config/SKILL.md
================================================
# db-init-config
Interactive setup for `config/config.cue` — the local configuration file needed before running `task db-init` and `task db-up`.
disable-model-invocation: true
---
## Instructions
You are helping the user create their `config/config.cue` file. This file is gitignored and contains local database credentials, an encryption key, and logger settings. It validates against the schema at `config/cue/schema.cue` and is exported to `config/config.json` by `task gen-config`.
### Step 1 — Collect admin target values
Use `AskUserQuestion` to collect the **admin PostgreSQL connection** values (used by `db-init` to connect as a superuser). Present all four questions in a single `AskUserQuestion` call:
1. **Admin DB host** — Options: `localhost` (Recommended), `127.0.0.1`. Header: `Admin host`. Question: "What is the host for your admin PostgreSQL connection?"
2. **Admin DB port** — Options: `5432` (Recommended), `5433`. Header: `Admin port`. Question: "What port is your admin PostgreSQL instance running on?"
3. **Admin DB name** — Options: `postgres` (Recommended), OS username. Header: `Admin DB`. Question: "What database should the admin connection use?" (Note: this is the database psql connects to, not the one being created.)
4. **Admin DB user** — Options: OS username (Recommended), `postgres`. Header: `Admin user`. Question: "What superuser should the admin connection use?"
After receiving answers, use a **second** `AskUserQuestion` call to ask:
5. **Admin DB password** — Options: `(empty / peer auth)` (Recommended), `Enter password`. Header: `Admin pass`. Question: "What is the password for the admin PostgreSQL user? (Leave empty if using peer/trust authentication.)"
### Step 2 — Collect app target values
Use `AskUserQuestion` to collect the **application database** values (what `db-init` will create). Present all six questions in a single call:
1. **App DB host** — Options: `localhost` (Recommended), `127.0.0.1`. Header: `App host`. Question: "What host should the application database use?"
2. **App DB port** — Options: `5432` (Recommended), `5433`. Header: `App port`. Question: "What port should the application database use?"
3. **App DB name** — Options: `dga_local` (Recommended), `diygoapi`. Header: `App DB name`. Question: "What should the application database be named?"
4. **App DB user** — Options: `demo_user` (Recommended), `dga_user`. Header: `App DB user`. Question: "What database user should be created for the application?"
5. **App DB password** — Options: `REPLACE_ME` (Recommended), `Enter password`. Header: `App DB pass`. Question: "What password should the application database user have?"
6. **App DB search path** — Options: `demo` (Recommended), `public`. Header: `Schema`. Question: "What PostgreSQL schema (search_path) should be used?"
### Step 3 — Generate encryption key
Run the following command and capture its output (trimmed):
```bash
go run ./cmd/newkey/main.go
```
Store the output as the encryption key value.
### Step 4 — Write config/config.cue
Write the file `config/config.cue` using the collected values. Use the exact template below, substituting the placeholder tokens with the values collected above.
For the admin password: if the user chose empty/peer auth, use an empty string `""`. Otherwise use the password they provided.
```cue
package config
_localAdminTarget: #Target & {
target: "local-admin"
server_listener_port: 8080
logger: {
min_log_level: "trace"
log_level: "debug"
log_error_stack: true
}
encryption_key: "{{ENCRYPTION_KEY}}"
database: {
host: "{{ADMIN_DB_HOST}}"
port: {{ADMIN_DB_PORT}}
name: "{{ADMIN_DB_NAME}}"
user: "{{ADMIN_DB_USER}}"
password: "{{ADMIN_DB_PASSWORD}}"
search_path: "public"
}
}
_localTarget: #Target & {
target: "local"
server_listener_port: 8080
logger: {
min_log_level: "trace"
log_level: "debug"
log_error_stack: true
}
encryption_key: "{{ENCRYPTION_KEY}}"
database: {
host: "{{APP_DB_HOST}}"
port: {{APP_DB_PORT}}
name: "{{APP_DB_NAME}}"
user: "{{APP_DB_USER}}"
password: "{{APP_DB_PASSWORD}}"
search_path: "{{APP_DB_SEARCH_PATH}}"
}
}
#Config & {
default_target: "local"
targets: [_localAdminTarget, _localTarget]
}
```
**Important**: The admin target's `search_path` is always `"public"` (the default PostgreSQL schema). The admin target's `password` must be a non-empty string to pass CUE validation — if the user chose empty/peer auth, use `"peer"` as a placeholder value (psql will use peer auth regardless when PGPASSWORD is empty in the db-init command).
### Step 5 — Run gen-config
Run `task gen-config` to validate and export the config:
```bash
task gen-config
```
If this succeeds, inform the user that their config is ready, and they can now run:
```
task db-init -- --db-admin-config-target local-admin
task db-up
```
If `task gen-config` fails, show the error to the user and help them fix the `config/config.cue` file.
================================================
FILE: .git/HEAD
================================================
ref: refs/heads/main
================================================
FILE: .git/config
================================================
[core]
repositoryformatversion = 1
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = https://github.com/gilcrest/diygoapi
tagOpt = --no-tags
fetch = +refs/heads/main:refs/remotes/origin/main
promisor = true
partialclonefilter = blob:limit=1048576
[branch "main"]
remote = origin
merge = refs/heads/main
================================================
FILE: .git/description
================================================
Unnamed repository; edit this file 'description' to name the repository.
================================================
FILE: .git/hooks/applypatch-msg.sample
================================================
#!/bin/sh
#
# An example hook script to check the commit log message taken by
# applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit. The hook is
# allowed to edit the commit message file.
#
# To enable this hook, rename this file to "applypatch-msg".
. git-sh-setup
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
:
================================================
FILE: .git/hooks/commit-msg.sample
================================================
#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit. The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".
# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
# This example catches duplicate Signed-off-by lines.
test "" = "$(grep '^Signed-off-by: ' "$1" |
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
echo >&2 Duplicate Signed-off-by lines.
exit 1
}
================================================
FILE: .git/hooks/fsmonitor-watchman.sample
================================================
#!/usr/bin/perl
use strict;
use warnings;
use IPC::Open2;
# An example hook script to integrate Watchman
# (https://facebook.github.io/watchman/) with git to speed up detecting
# new and modified files.
#
# The hook is passed a version (currently 2) and last update token
# formatted as a string and outputs to stdout a new update token and
# all files that have been modified since the update token. Paths must
# be relative to the root of the working tree and separated by a single NUL.
#
# To enable this hook, rename this file to "query-watchman" and set
# 'git config core.fsmonitor .git/hooks/query-watchman'
#
my ($version, $last_update_token) = @ARGV;
# Uncomment for debugging
# print STDERR "$0 $version $last_update_token\n";
# Check the hook interface version
if ($version ne 2) {
die "Unsupported query-fsmonitor hook version '$version'.\n" .
"Falling back to scanning...\n";
}
my $git_work_tree = get_working_dir();
my $retry = 1;
my $json_pkg;
eval {
require JSON::XS;
$json_pkg = "JSON::XS";
1;
} or do {
require JSON::PP;
$json_pkg = "JSON::PP";
};
launch_watchman();
sub launch_watchman {
my $o = watchman_query();
if (is_work_tree_watched($o)) {
output_result($o->{clock}, @{$o->{files}});
}
}
sub output_result {
my ($clockid, @files) = @_;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# binmode $fh, ":utf8";
# print $fh "$clockid\n@files\n";
# close $fh;
binmode STDOUT, ":utf8";
print $clockid;
print "\0";
local $, = "\0";
print @files;
}
sub watchman_clock {
my $response = qx/watchman clock "$git_work_tree"/;
die "Failed to get clock id on '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
return $json_pkg->new->utf8->decode($response);
}
sub watchman_query {
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
or die "open2() failed: $!\n" .
"Falling back to scanning...\n";
# In the query expression below we're asking for names of files that
# changed since $last_update_token but not from the .git folder.
#
# To accomplish this, we're using the "since" generator to use the
# recency index to select candidate nodes and "fields" to limit the
# output to file names only. Then we're using the "expression" term to
# further constrain the results.
my $last_update_line = "";
if (substr($last_update_token, 0, 1) eq "c") {
$last_update_token = "\"$last_update_token\"";
$last_update_line = qq[\n"since": $last_update_token,];
}
my $query = <<" END";
["query", "$git_work_tree", {$last_update_line
"fields": ["name"],
"expression": ["not", ["dirname", ".git"]]
}]
END
# Uncomment for debugging the watchman query
# open (my $fh, ">", ".git/watchman-query.json");
# print $fh $query;
# close $fh;
print CHLD_IN $query;
close CHLD_IN;
my $response = do {local $/; <CHLD_OUT>};
# Uncomment for debugging the watch response
# open ($fh, ">", ".git/watchman-response.json");
# print $fh $response;
# close $fh;
die "Watchman: command returned no output.\n" .
"Falling back to scanning...\n" if $response eq "";
die "Watchman: command returned invalid output: $response\n" .
"Falling back to scanning...\n" unless $response =~ /^\{/;
return $json_pkg->new->utf8->decode($response);
}
sub is_work_tree_watched {
my ($output) = @_;
my $error = $output->{error};
if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
$retry--;
my $response = qx/watchman watch "$git_work_tree"/;
die "Failed to make watchman watch '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
$output = $json_pkg->new->utf8->decode($response);
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# close $fh;
# Watchman will always return all files on the first query so
# return the fast "everything is dirty" flag to git and do the
# Watchman query just to get it over with now so we won't pay
# the cost in git to look up each individual file.
my $o = watchman_clock();
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
output_result($o->{clock}, ("/"));
$last_update_token = $o->{clock};
eval { launch_watchman() };
return 0;
}
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
return 1;
}
sub get_working_dir {
my $working_dir;
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
$working_dir = Win32::GetCwd();
$working_dir =~ tr/\\/\//;
} else {
require Cwd;
$working_dir = Cwd::cwd();
}
return $working_dir;
}
================================================
FILE: .git/hooks/post-update.sample
================================================
#!/bin/sh
#
# An example hook script to prepare a packed repository for use over
# dumb transports.
#
# To enable this hook, rename this file to "post-update".
exec git update-server-info
================================================
FILE: .git/hooks/pre-applypatch.sample
================================================
#!/bin/sh
#
# An example hook script to verify what is about to be committed
# by applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-applypatch".
. git-sh-setup
precommit="$(git rev-parse --git-path hooks/pre-commit)"
test -x "$precommit" && exec "$precommit" ${1+"$@"}
:
================================================
FILE: .git/hooks/pre-commit.sample
================================================
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --
================================================
FILE: .git/hooks/pre-merge-commit.sample
================================================
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git merge" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message to
# stderr if it wants to stop the merge commit.
#
# To enable this hook, rename this file to "pre-merge-commit".
. git-sh-setup
test -x "$GIT_DIR/hooks/pre-commit" &&
exec "$GIT_DIR/hooks/pre-commit"
:
================================================
FILE: .git/hooks/pre-push.sample
================================================
#!/bin/sh
# An example hook script to verify what is about to be pushed. Called by "git
# push" after it has checked the remote status, but before anything has been
# pushed. If this script exits with a non-zero status nothing will be pushed.
#
# This hook is called with the following parameters:
#
# $1 -- Name of the remote to which the push is being done
# $2 -- URL to which the push is being done
#
# If pushing without using a named remote those arguments will be equal.
#
# Information about the commits which are being pushed is supplied as lines to
# the standard input in the form:
#
# <local ref> <local oid> <remote ref> <remote oid>
#
# This sample shows how to prevent push of commits where the log message starts
# with "WIP" (work in progress).
remote="$1"
url="$2"
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
while read local_ref local_oid remote_ref remote_oid
do
if test "$local_oid" = "$zero"
then
# Handle delete
:
else
if test "$remote_oid" = "$zero"
then
# New branch, examine all commits
range="$local_oid"
else
# Update to existing branch, examine new commits
range="$remote_oid..$local_oid"
fi
# Check for WIP commit
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
if test -n "$commit"
then
echo >&2 "Found WIP commit in $local_ref, not pushing"
exit 1
fi
fi
done
exit 0
================================================
FILE: .git/hooks/pre-rebase.sample
================================================
#!/bin/sh
#
# Copyright (c) 2006, 2008 Junio C Hamano
#
# The "pre-rebase" hook is run just before "git rebase" starts doing
# its job, and can prevent the command from running by exiting with
# non-zero status.
#
# The hook is called with the following parameters:
#
# $1 -- the upstream the series was forked from.
# $2 -- the branch being rebased (or empty when rebasing the current branch).
#
# This sample shows how to prevent topic branches that are already
# merged to 'next' branch from getting rebased, because allowing it
# would result in rebasing already published history.
publish=next
basebranch="$1"
if test "$#" = 2
then
topic="refs/heads/$2"
else
topic=`git symbolic-ref HEAD` ||
exit 0 ;# we do not interrupt rebasing detached HEAD
fi
case "$topic" in
refs/heads/??/*)
;;
*)
exit 0 ;# we do not interrupt others.
;;
esac
# Now we are dealing with a topic branch being rebased
# on top of master. Is it OK to rebase it?
# Does the topic really exist?
git show-ref -q "$topic" || {
echo >&2 "No such branch $topic"
exit 1
}
# Is topic fully merged to master?
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
if test -z "$not_in_master"
then
echo >&2 "$topic is fully merged to master; better remove it."
exit 1 ;# we could allow it, but there is no point.
fi
# Is topic ever merged to next? If so you should not be rebasing it.
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
only_next_2=`git rev-list ^master ${publish} | sort`
if test "$only_next_1" = "$only_next_2"
then
not_in_topic=`git rev-list "^$topic" master`
if test -z "$not_in_topic"
then
echo >&2 "$topic is already up to date with master"
exit 1 ;# we could allow it, but there is no point.
else
exit 0
fi
else
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
/usr/bin/perl -e '
my $topic = $ARGV[0];
my $msg = "* $topic has commits already merged to public branch:\n";
my (%not_in_next) = map {
/^([0-9a-f]+) /;
($1 => 1);
} split(/\n/, $ARGV[1]);
for my $elem (map {
/^([0-9a-f]+) (.*)$/;
[$1 => $2];
} split(/\n/, $ARGV[2])) {
if (!exists $not_in_next{$elem->[0]}) {
if ($msg) {
print STDERR $msg;
undef $msg;
}
print STDERR " $elem->[1]\n";
}
}
' "$topic" "$not_in_next" "$not_in_master"
exit 1
fi
<<\DOC_END
This sample hook safeguards topic branches that have been
published from being rewound.
The workflow assumed here is:
* Once a topic branch forks from "master", "master" is never
merged into it again (either directly or indirectly).
* Once a topic branch is fully cooked and merged into "master",
it is deleted. If you need to build on top of it to correct
earlier mistakes, a new topic branch is created by forking at
the tip of the "master". This is not strictly necessary, but
it makes it easier to keep your history simple.
* Whenever you need to test or publish your changes to topic
branches, merge them into "next" branch.
The script, being an example, hardcodes the publish branch name
to be "next", but it is trivial to make it configurable via
$GIT_DIR/config mechanism.
With this workflow, you would want to know:
(1) ... if a topic branch has ever been merged to "next". Young
topic branches can have stupid mistakes you would rather
clean up before publishing, and things that have not been
merged into other branches can be easily rebased without
affecting other people. But once it is published, you would
not want to rewind it.
(2) ... if a topic branch has been fully merged to "master".
Then you can delete it. More importantly, you should not
build on top of it -- other people may already want to
change things related to the topic as patches against your
"master", so if you need further changes, it is better to
fork the topic (perhaps with the same name) afresh from the
tip of "master".
Let's look at this example:
o---o---o---o---o---o---o---o---o---o "next"
/ / / /
/ a---a---b A / /
/ / / /
/ / c---c---c---c B /
/ / / \ /
/ / / b---b C \ /
/ / / / \ /
---o---o---o---o---o---o---o---o---o---o---o "master"
A, B and C are topic branches.
* A has one fix since it was merged up to "next".
* B has finished. It has been fully merged up to "master" and "next",
and is ready to be deleted.
* C has not merged to "next" at all.
We would want to allow C to be rebased, refuse A, and encourage
B to be deleted.
To compute (1):
git rev-list ^master ^topic next
git rev-list ^master next
if these match, topic has not merged in next at all.
To compute (2):
git rev-list master..topic
if this is empty, it is fully merged to "master".
DOC_END
================================================
FILE: .git/hooks/pre-receive.sample
================================================
#!/bin/sh
#
# An example hook script to make use of push options.
# The example simply echoes all push options that start with 'echoback='
# and rejects all pushes when the "reject" push option is used.
#
# To enable this hook, rename this file to "pre-receive".
if test -n "$GIT_PUSH_OPTION_COUNT"
then
i=0
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
do
eval "value=\$GIT_PUSH_OPTION_$i"
case "$value" in
echoback=*)
echo "echo from the pre-receive-hook: ${value#*=}" >&2
;;
reject)
exit 1
esac
i=$((i + 1))
done
fi
================================================
FILE: .git/hooks/prepare-commit-msg.sample
================================================
#!/bin/sh
#
# An example hook script to prepare the commit log message.
# Called by "git commit" with the name of the file that has the
# commit message, followed by the description of the commit
# message's source. The hook's purpose is to edit the commit
# message file. If the hook fails with a non-zero status,
# the commit is aborted.
#
# To enable this hook, rename this file to "prepare-commit-msg".
# This hook includes three examples. The first one removes the
# "# Please enter the commit message..." help message.
#
# The second includes the output of "git diff --name-status -r"
# into the message, just before the "git status" output. It is
# commented because it doesn't cope with --amend or with squashed
# commits.
#
# The third example adds a Signed-off-by line to the message, that can
# still be edited. This is rarely a good idea.
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
# case "$COMMIT_SOURCE,$SHA1" in
# ,|template,)
# /usr/bin/perl -i.bak -pe '
# print "\n" . `git diff --cached --name-status -r`
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
# *) ;;
# esac
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
# if test -z "$COMMIT_SOURCE"
# then
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
# fi
================================================
FILE: .git/hooks/push-to-checkout.sample
================================================
#!/bin/sh
# An example hook script to update a checked-out tree on a git push.
#
# This hook is invoked by git-receive-pack(1) when it reacts to git
# push and updates reference(s) in its repository, and when the push
# tries to update the branch that is currently checked out and the
# receive.denyCurrentBranch configuration variable is set to
# updateInstead.
#
# By default, such a push is refused if the working tree and the index
# of the remote repository has any difference from the currently
# checked out commit; when both the working tree and the index match
# the current commit, they are updated to match the newly pushed tip
# of the branch. This hook is to be used to override the default
# behaviour; however the code below reimplements the default behaviour
# as a starting point for convenient modification.
#
# The hook receives the commit with which the tip of the current
# branch is going to be updated:
commit=$1
# It can exit with a non-zero status to refuse the push (when it does
# so, it must not modify the index or the working tree).
die () {
echo >&2 "$*"
exit 1
}
# Or it can make any necessary changes to the working tree and to the
# index to bring them to the desired state when the tip of the current
# branch is updated to the new commit, and exit with a zero status.
#
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
# in order to emulate git fetch that is run in the reverse direction
# with git push, as the two-tree form of git read-tree -u -m is
# essentially the same as git switch or git checkout that switches
# branches while keeping the local changes in the working tree that do
# not interfere with the difference between the branches.
# The below is a more-or-less exact translation to shell of the C code
# for the default behaviour for git's push-to-checkout hook defined in
# the push_to_deploy() function in builtin/receive-pack.c.
#
# Note that the hook will be executed from the repository directory,
# not from the working tree, so if you want to perform operations on
# the working tree, you will have to adapt your code accordingly, e.g.
# by adding "cd .." or using relative paths.
if ! git update-index -q --ignore-submodules --refresh
then
die "Up-to-date check failed"
fi
if ! git diff-files --quiet --ignore-submodules --
then
die "Working directory has unstaged changes"
fi
# This is a rough translation of:
#
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
if git cat-file -e HEAD 2>/dev/null
then
head=HEAD
else
head=$(git hash-object -t tree --stdin </dev/null)
fi
if ! git diff-index --quiet --cached --ignore-submodules $head --
then
die "Working directory has staged changes"
fi
if ! git read-tree -u -m "$commit"
then
die "Could not update working tree to new HEAD"
fi
================================================
FILE: .git/hooks/sendemail-validate.sample
================================================
#!/bin/sh
# An example hook script to validate a patch (and/or patch series) before
# sending it via email.
#
# The hook should exit with non-zero status after issuing an appropriate
# message if it wants to prevent the email(s) from being sent.
#
# To enable this hook, rename this file to "sendemail-validate".
#
# By default, it will only check that the patch(es) can be applied on top of
# the default upstream branch without conflicts in a secondary worktree. After
# validation (successful or not) of the last patch of a series, the worktree
# will be deleted.
#
# The following config variables can be set to change the default remote and
# remote ref that are used to apply the patches against:
#
# sendemail.validateRemote (default: origin)
# sendemail.validateRemoteRef (default: HEAD)
#
# Replace the TODO placeholders with appropriate checks according to your
# needs.
validate_cover_letter () {
file="$1"
# TODO: Replace with appropriate checks (e.g. spell checking).
true
}
validate_patch () {
file="$1"
# Ensure that the patch applies without conflicts.
git am -3 "$file" || return
# TODO: Replace with appropriate checks for this patch
# (e.g. checkpatch.pl).
true
}
validate_series () {
# TODO: Replace with appropriate checks for the whole series
# (e.g. quick build, coding style checks, etc.).
true
}
# main -------------------------------------------------------------------------
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
then
remote=$(git config --default origin --get sendemail.validateRemote) &&
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
git config --replace-all sendemail.validateWorktree "$worktree"
else
worktree=$(git config --get sendemail.validateWorktree)
fi || {
echo "sendemail-validate: error: failed to prepare worktree" >&2
exit 1
}
unset GIT_DIR GIT_WORK_TREE
cd "$worktree" &&
if grep -q "^diff --git " "$1"
then
validate_patch "$1"
else
validate_cover_letter "$1"
fi &&
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
then
git config --unset-all sendemail.validateWorktree &&
trap 'git worktree remove -ff "$worktree"' EXIT &&
validate_series
fi
================================================
FILE: .git/hooks/update.sample
================================================
#!/bin/sh
#
# An example hook script to block unannotated tags from entering.
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
#
# To enable this hook, rename this file to "update".
#
# Config
# ------
# hooks.allowunannotated
# This boolean sets whether unannotated tags will be allowed into the
# repository. By default they won't be.
# hooks.allowdeletetag
# This boolean sets whether deleting tags will be allowed in the
# repository. By default they won't be.
# hooks.allowmodifytag
# This boolean sets whether a tag may be modified after creation. By default
# it won't be.
# hooks.allowdeletebranch
# This boolean sets whether deleting branches will be allowed in the
# repository. By default they won't be.
# hooks.denycreatebranch
# This boolean sets whether remotely creating branches will be denied
# in the repository. By default this is allowed.
#
# --- Command line
refname="$1"
oldrev="$2"
newrev="$3"
# --- Safety check
if [ -z "$GIT_DIR" ]; then
echo "Don't run this script from the command line." >&2
echo " (if you want, you could supply GIT_DIR then run" >&2
echo " $0 <ref> <oldrev> <newrev>)" >&2
exit 1
fi
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
exit 1
fi
# --- Config
allowunannotated=$(git config --type=bool hooks.allowunannotated)
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
# check for no description
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
case "$projectdesc" in
"Unnamed repository"* | "")
echo "*** Project description file hasn't been set" >&2
exit 1
;;
esac
# --- Check types
# if $newrev is 0000...0000, it's a commit to delete a ref.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
if [ "$newrev" = "$zero" ]; then
newrev_type=delete
else
newrev_type=$(git cat-file -t $newrev)
fi
case "$refname","$newrev_type" in
refs/tags/*,commit)
# un-annotated tag
short_refname=${refname##refs/tags/}
if [ "$allowunannotated" != "true" ]; then
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
exit 1
fi
;;
refs/tags/*,delete)
# delete tag
if [ "$allowdeletetag" != "true" ]; then
echo "*** Deleting a tag is not allowed in this repository" >&2
exit 1
fi
;;
refs/tags/*,tag)
# annotated tag
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
then
echo "*** Tag '$refname' already exists." >&2
echo "*** Modifying a tag is not allowed in this repository." >&2
exit 1
fi
;;
refs/heads/*,commit)
# branch
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
echo "*** Creating a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/heads/*,delete)
# delete branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/remotes/*,commit)
# tracking branch
;;
refs/remotes/*,delete)
# delete tracking branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
exit 1
fi
;;
*)
# Anything else (is there anything else?)
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
exit 1
;;
esac
# --- Finished
exit 0
================================================
FILE: .git/info/exclude
================================================
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~
================================================
FILE: .git/logs/HEAD
================================================
0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> 1776662781 +0000 clone: from https://github.com/gilcrest/diygoapi
================================================
FILE: .git/logs/refs/heads/main
================================================
0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> 1776662781 +0000 clone: from https://github.com/gilcrest/diygoapi
================================================
FILE: .git/logs/refs/remotes/origin/HEAD
================================================
0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> 1776662781 +0000 clone: from https://github.com/gilcrest/diygoapi
================================================
FILE: .git/objects/pack/pack-4cf8052845b33e6cfeabee017e720515ff38889e.promisor
================================================
7a4aacf5c3ee84be2a49944859c55cb586632fc7 refs/heads/main
================================================
FILE: .git/packed-refs
================================================
# pack-refs with: peeled fully-peeled sorted
7a4aacf5c3ee84be2a49944859c55cb586632fc7 refs/remotes/origin/main
================================================
FILE: .git/refs/heads/main
================================================
7a4aacf5c3ee84be2a49944859c55cb586632fc7
================================================
FILE: .git/refs/remotes/origin/HEAD
================================================
ref: refs/remotes/origin/main
================================================
FILE: .git/shallow
================================================
7a4aacf5c3ee84be2a49944859c55cb586632fc7
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
================================================
FILE: .gitignore
================================================
# Ignore all
*
# Unignore all with extensions
!*.*
# Unignore all directories
!*/
# Unignore Dockerfile
!Dockerfile
### Above combination will ignore all files without extension ###
# vs code related files
.vscode/
tasks.json
# OS generated files #
######################
.DS_Store
# Binaries for programs and plugins
*.exe
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# IntelliJ Goland directory and files
.idea/
# Genesis response file contains sensitive info
/config/genesis/response.json
# bin directory
/bin/
# Local configuration (generated and user-specific)
/config/config.cue
/config/config.json
# Claude Code local settings (per-developer preferences)
.claude/settings.local.json
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
This file provides guidance to [Claude Code](https://claude.ai/code) when working with code in this repository.
## Project Overview
DIY Go API is a RESTful API template (built with Go) backed by PostgreSQL. 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. It 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.).
The "business" domain is a simple movie CRUD (**C**reate, **R**ead, **U**pdate, **D**elete) API, but the underlying data model is set up to enable a B2B multi-tenant SaaS, which is overkill for a simple CRUD app, however, it's the model I wanted to create and teach myself.
## Build & Run Commands
**Build tool**: [Taskfile.dev](https://taskfile.dev/) — tasks defined in `Taskfile.yml`.
| Command | Description |
|---|---|
| `task run` | Run the server |
| `task test` | Run all tests |
| `task test-verbose` | Run all tests (verbose) |
| `go test -v -run TestFunctionName ./path/to/package` | Run a single test |
| `task new-key` | Generate a new encryption key |
| `task db-init` | Initialize database user, database, and schema via psql |
| `task db-teardown` | Drop database schema, database, and user via psql |
| `task db-up` | Run database DDL migrations |
| `task gen-config` | Generate config from CUE schemas |
Database tasks (`db-init`, `db-teardown`, `db-up`) read connection info from `./config/config.json` by default (using the `default_target` in the config). `db-init` and `db-teardown` require `--db-admin-config-target` (admin connection) and optionally take `--app-config-target` (defaults to `default_target`). Example: `task db-init -- --db-admin-config-target local-admin`. To override the migration target: `task db-up -- --target prod`.
--------
## Architecture
### Layer Structure
```
HTTP Request → server (routes/middleware/handlers) → service (business logic) → sqldb/datastore (data access) → PostgreSQL
```
- **Root package (`diygoapi.go`)**: Domain types, interfaces (`Datastorer`, service interfaces like `MovieServicer`, `OrgServicer`, `AppServicer`), and request/response structs. This is inspired by the [WTF Dial app repo](https://github.com/benbjohnson/wtf) and [accompanying blog](https://www.gobeyond.dev/) from [Ben Johnson](https://github.com/benbjohnson) — domain interfaces are defined here and implemented in `service/`.
- **`server/`**: HTTP routing (`routes.go`), middleware chains (`middleware.go`), and handlers (`handlers.go`). Uses [`justinas/alice`](https://github.com/justinas/alice) for composable middleware. Routes use Go 1.22+ method-pattern syntax (e.g., `"POST /api/v1/movies"`).
- **`service/`**: Business logic implementations. Services are structs with a `Datastorer` field and optional `EncryptionKey`. Transactions are managed here. Services are struct literals with injected dependencies (no constructor functions).
- **`sqldb/datastore/`**: SQL queries generated by [sqlc](https://sqlc.dev/). Type-safe database access via `pgx/v5`. No ORM — raw SQL + sqlc code generation.
- **`cmd/`**: CLI wiring — flag parsing via [`peterbourgon/ff/v3`](https://github.com/peterbourgon/ff) from [Peter Bourgon](https://peter.bourgon.org), server initialization, dependency injection.
### Middleware Chain
Every route uses a middleware chain built with `alice`. A typical chain looks like:
```
loggerChain → addRequestHandlerPattern → enforceJSONContentType → appHandler → authHandler → authorizeUserHandler → jsonContentTypeResponse → handler
```
### Error Handling (errs package)
Based on [Rob Pike's Upspin error pattern](https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html) (modified to meet my needs). The post is many years old now, but I find the lessons there still hold true.
By convention, every function declares `const op errs.Op = "package/Function"` as the first line and wraps errors with `errs.E(op, ...)`. The `errs.E` function is variadic and can take several different types to form the custom `errs.Error` struct.
Key types:
- `Op` — operation trace, builds the error stack as errors propagate up the call stack
- `Kind` — error classification (`Validation`, `Unauthenticated`, `Unauthorized`, `Internal`, `Database`, etc.)
- `Code` — machine-readable short code for client error handling
- `Param` — the parameter related to the error
- `Realm` — used in the `WWW-Authenticate` response header
`errs.HTTPErrorResponse()` maps `Kind` to HTTP status codes. Internal/Database errors are never leaked to clients — they return a generic "internal server error" message. `Unauthenticated` and `Unauthorized` errors return empty response bodies per the spec.
> Note: There are helpers like `errs.MissingField` (returns "field is required") and `errs.InputUnwanted` for common validation patterns.
### Authentication & Authorization
- OAuth2 via Google (token validated against Google's OAuth2 v2 API)
- Bearer token required in `Authorization` header on all requests
- App authentication via `X-APP-ID` and `X-API-KEY` headers (or falls back to provider Client ID lookup)
- RBAC: Users have roles, roles have permissions, permissions map to resources (endpoints)
- Context carries `App`, `User`, and `AuthParams` through the request lifecycle
### Database
- PostgreSQL with `pgx/v5` connection pooling
- Migrations: SQL files in `scripts/db/migrations/up/` (numbered 000–014)
- Uses PostgreSQL schemas for tenant isolation (`search_path`)
### Configuration
The [ff](https://github.com/peterbourgon/ff) library from [Peter Bourgon](https://peter.bourgon.org) is used to parse flags. Flags take precedence, so if a flag is passed, that will be used. 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. The config file defaults to `./config/config.json`.
The CUE-based config setup uses a split layout:
- **`config/cue/schema.cue`** — the shared validation schema (checked into git)
- **`config/config.cue`** — local config values with credentials (gitignored)
- **`config/config.json`** — generated output from `task gen-config` (gitignored)
Key env vars: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_SEARCH_PATH`, `ENCRYPT_KEY`, `PORT`, `LOG_LEVEL`, `LOG_LEVEL_MIN`, `LOG_ERROR_STACK`
### Logging
Structured JSON logging via [`rs/zerolog`](https://github.com/rs/zerolog) from [Olivier Poitrey](https://github.com/rs). The logger is initialized at startup and added to the request context through middleware, which pre-populates fields like request method, URL, status, duration, remote IP, user agent, and a unique `Request-Id`. The `Request-Id` is also sent back as a response header, allowing you to link request and error logs. GCP-compatible log hooks are in the `logger/` package.
--------
## Key Conventions
- **Testing**: Uses [`frankban/quicktest`](https://github.com/frankban/quicktest) (`c := qt.New(t)`), table-driven subtests
- **Error ops**: Always `const op errs.Op = "package/Function"` as first line, wrap with `errs.E(op, err)`
- **External IDs**: API responses use external IDs (UUIDs), never internal database IDs. I try to never expose primary keys.
- **Service constructors**: Services are struct literals with injected dependencies (no constructor functions)
- **Domain interfaces**: Defined in root package, implemented in `service/`
--------
## Key Terms
- **Person**: A being that has certain capacities or attributes such as reason, morality, consciousness or self-consciousness.
- **User**: Akin to a persona — 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). Roles are assigned at the User level for fine-grained access control.
- **App**: An application that interacts with the system. An App always belongs to just one Org.
- **Org**: Represents an Organization (company, institution or any other organized body of people with a particular purpose). An Org can have multiple Persons/Users and Apps.
================================================
FILE: README.md
================================================
# 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 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.
[](https://pkg.go.dev/github.com/gilcrest/diygoapi) [](https://goreportcard.com/report/github.com/gilcrest/diygoapi)
## 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 (**C**reate, **R**ead, **U**pdate, **D**elete) operations for a movie database. All paths to files or directories are from the project root.
## Minimum Requirements
- [Go](https://go.dev/)
- [PostgreSQL](https://www.postgresql.org/) - Database
- [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2/web-server) - authentication
- [Task](https://taskfile.dev/) - task runner for build and script execution
- [CUE](https://cuelang.org/) - config file generation
--------
## Disclaimer
Briefly, the data model for this project is set up to enable a B2B multi-tenant SAAS, which is overkill for a simple CRUD app, however, it's the model I wanted to create and teach myself. That said, it can serve only one tenant just fine.
## Key Terms
- `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."
- `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.
- `App`: is an application that interacts with the system. An App always belongs to just one Org.
- `Org`: represents an Organization (company, institution or any other organized body of people with a particular purpose). An Org can have multiple Persons/Users and Apps.
----------
## Getting Started
The following are basic instructions for getting started.
### Step 1 - Get the code
Clone the code:
```shell
$ git clone https://github.com/gilcrest/diygoapi.git
Cloning into 'diygoapi'...
```
or use the [Github CLI](https://cli.github.com/) (also written in Go!):
```shell
$ 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](https://developers.google.com/identity/protocols/oauth2/web-server) for these APIs. To use this, you need to setup a Client ID and Client Secret and obtain an access token. The instructions [here](https://developers.google.com/identity/protocols/oauth2) are great.
After Oauth2 setup with Google, I recommend the [Google Oauth2 Playground](https://developers.google.com/oauthplayground/) 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](https://pkg.go.dev/google.golang.org/api/oauth2/v2) 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.
> Note: For more details on the authentication model, see the [Authentication Detail](#authentication-detail) section below.
#### Authorization
If the user's Bearer token is ***valid***, the user must be _authorized_. Users must first register with the system and be given a role. Currently, the SelfRegister service accommodates this and automatically assigns a default _role_ `movieAdmin` (functionality will be added eventually for one person registering another).
_Roles_ are assigned _permissions_ and permissions are assigned to resources (service endpoints). The system uses a role-based access control model. The user's role is used to determine if the user is authorized to access a particular endpoint/resource.
The `movieAdmin` role is set up to grant access to all resources. It's a demo... so why not?
> Note: For more details on the authorization model, see the [Authorization Detail](#authorization-detail) section below.
--------
### Step 3 - Configuration
All programs in this project (the web server, database tasks, etc.) use the [ff](https://github.com/peterbourgon/ff) library from [Peter Bourgon](https://peter.bourgon.org) for configuration. The priority order is: **CLI flags > environment variables > config file > defaults**. The config file defaults to `./config/config.json`, so the simplest path, for local development, is to create that file.
> If you are using [Claude Code](https://claude.ai/code), you can run `/db-init-config` to be guided through configuration setup interactively — it collects your database connection values, generates a fresh encryption key, writes `config/config.cue`, and runs `task gen-config` for you.
#### Generate a new encryption key
Regardless of which configuration approach you choose, you need a 256-bit ciphertext string, which can be parsed to a 32 byte encryption key. Generate the ciphertext with `task new-key`:
```shell
$ task new-key
Key Ciphertext: [31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06]
```
> Copy the key ciphertext between the brackets to your clipboard to use in one of the options below
#### Option 1 (Recommended for Local Development) - Config File
> 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](https://cloud.google.com/secret-manager) or [HashiCorp Vault](https://learn.hashicorp.com/tutorials/vault/getting-started-intro?in=vault/getting-started) [See Issue 91](https://github.com/gilcrest/diygoapi/issues/91).
The config uses a multi-target layout where each target (e.g. `local`, `staging`) has its own settings. Create or edit the JSON file at `./config/config.json`. Update the `encryption_key`, `database` fields (`host`, `port`, `name`, `user`, `password`, `search_path`) and other settings as appropriate for your `PostgreSQL` installation.
```json
{
"default_target": "local",
"targets": [
{
"target": "local",
"server_listener_port": 8080,
"logger": {
"min_log_level": "trace",
"log_level": "debug",
"log_error_stack": false
},
"encryption_key": "31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06",
"database": {
"host": "localhost",
"port": 5432,
"name": "dga_local",
"user": "demo_user",
"password": "REPLACE_ME",
"search_path": "demo"
}
}
]
}
```
> Setting the [schema search path](https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-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).
##### Generate config file using CUE (Optional)
If you prefer, you can generate the JSON config file using [CUE](https://cuelang.org/).
The CUE-based config uses a split layout:
- **`config/cue/schema.cue`** -- the shared validation schema (checked into git)
- **`config/config.cue`** -- local config values with credentials (gitignored)
- **`config/config.json`** -- generated output (gitignored)
Edit the `./config/config.cue` file. Update the `encryption_key`, `database` fields (`host`, `port`, `name`, `user`, `password`, `search_path`) and other settings as appropriate for your `PostgreSQL` installation.
After modifying the CUE file, run the following from project root:
```shell
$ task gen-config
```
This should produce the JSON config file mentioned above (at `./config/config.json`).
#### Option 2 - Environment Variables
As an alternative, you can set environment variables directly through bash or whatever strategy you use. Environment variables override config file values. An example bash script:
```bash
#!/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 3 - Command Line Flags
For full control, you can pass command line flags directly when running a program. Flags take the highest priority, overriding both environment variables and config file values. The following table lists all available flags, their equivalent environment variables, and defaults:
| 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 | info |
| log-level-min | sets the minimum accepted logging level | LOG_LEVEL_MIN | trace |
| 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 | localhost |
| 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 | |
For example:
```bash
$ go run ./cmd/diy/main.go -db-name=dga_local -db-user=demo_user -db-password=REPLACE_ME -db-search-path=demo -encrypt-key=31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06
```
### Step 4 - Database Initialization
The following steps create 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 [Task](https://taskfile.dev/). To understand database migrations and how they are structured in this project, you can watch [this talk](https://youtu.be/w07butydI5Q) I gave to the [Boston Golang meetup group](https://www.meetup.com/bostongo/?_cookie-check=1Gx8ms5NN8GhlaLJ) 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`.
> All database tasks read connection info from `./config/config.json` by default, using the `default_target` defined in the config. To target a different environment, pass `--target` via CLI args, e.g. `task db-up -- --target staging`. You can also override the target with the `TARGET` environment variable.
#### Initialize the Database
Database initialization (creating the user, database, and schema) requires elevated privileges. Define a `local-admin` target in your config with a superuser (or a role that has `CREATEROLE` and `CREATEDB`), then run:
```shell
$ task db-init -- --db-admin-config-target local-admin
```
This idempotently creates the database user, database, and schema using values from the config's default target. To specify a different app target: `task db-init -- --db-admin-config-target local-admin --app-config-target staging`.
#### Run the Database Up Migration
Fifteen database migration scripts are run as part of the up migration:
```shell
$ task db-up
```
> Note: At any time, you can drop all the database objects created as part of the up migration using the down migration scripts in `./scripts/db/migrations/down/`.
#### 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.
To call Genesis, send a `POST` request to `/api/v1/genesis`. The Genesis endpoint uses a special authentication middleware (`genesisAuthHandler`) that only validates the user's Bearer token — no app authentication is required, since apps don't exist yet. An example request body is provided at `./config/genesis/request.json`:
```bash
$ curl --location --request POST 'http://127.0.0.1:8080/api/v1/genesis' \
--header 'Content-Type: application/json' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \
--data @./config/genesis/request.json
```
The request body defines:
- **`user`**: The OAuth2 provider and token for the user calling Genesis (this user becomes the system's first admin).
- **`org`**: The user-initiated organization and app to create (your organization for interacting with the Movie APIs).
- **`permissions`**: The set of permissions (resource + operation pairs) to create, e.g. `POST /api/v1/movies`.
- **`roles`**: The roles to create, each with a list of permissions to assign to it.
> Edit `./config/genesis/request.json` before calling the service — replace the `user.token` with a valid Google OAuth2 access token and `org.app.oauth2_provider_client_id` with your Google OAuth2 Client ID.
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. 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.)
Genesis creates the following seed data within a single database transaction:
1. **Principal Org** — The administrative organization, with a "Developer Dashboard" app created within it.
2. **Test Org** — The test organization, with a "Test App" and a test user created within it.
3. **User-Initiated Org** — Your organization (name and description from the request body), with the app you specified created within it.
4. **Permissions** — All resource/operation pairs from the request (e.g. `GET /api/v1/movies`, `POST /api/v1/movies`).
5. **Roles** — All roles from the request (e.g. `sysAdmin`, `movieAdmin`), each linked to their specified permissions.
6. **Role Grants** — The authenticated user (the person calling Genesis) is granted all requested roles in both the Principal and User-Initiated orgs. A test role is granted to the test user in the Test org.
The response contains three nodes — `principal`, `test`, and `userInitiated` — each with the org and app details:
```json
{
"principal": {
"external_id": "qAG9Gn34ruud86a_",
"name": "Principal",
"kind_description": "principal",
"description": "The Principal org represents the first organization created in the database...",
"app": {
"external_id": "iK6yM7pBTTSXJXCF",
"name": "Developer Dashboard",
"api_keys": [{ "key": "j-DZh4olLswgodsPDA2NsA==", "deactivation_date": "2099-12-31 ..." }]
}
},
"test": {
"external_id": "h-o4hvaClqlM0V3d",
"name": "Test Org",
"kind_description": "test",
"app": {
"external_id": "upbfVbnyqByyuOUs",
"name": "Test App",
"api_keys": [{ "key": "sD-i1kYNWtGaFNauXhKZ6A==", "deactivation_date": "2099-12-31 ..." }]
}
},
"userInitiated": {
"external_id": "a2gmurv3P9Ws1ybk",
"name": "Movie Makers Unlimited",
"kind_description": "standard",
"app": {
"external_id": "zakvyaRpCu4zmt1-",
"name": "Movie Makers App",
"api_keys": [{ "key": "2BJd-AYbXJdzHWmiphtxxA==", "deactivation_date": "2099-12-31 ..." }]
}
}
}
```
> The full response is saved to `./config/genesis/response.json` and can be retrieved later via `GET /api/v1/genesis`.
Most importantly, the `userInitiated` node contains the app `external_id` and `api_keys[0].key` values needed as the `x-app-id` and `x-api-key` headers for all subsequent API calls (see [Step 7](#step-7---send-requests)).
--------
### 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:
```shell
$ task test
```
> Note: Some tests require a running database with Genesis data. Packages without database dependencies can be tested independently.
### Step 6 - Run the Web Server
With configuration handled in [Step 3](#step-3---configuration), start the web server with Task:
```shell
$ task run
{"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"}
{"level":"info","time":1675700939,"severity":"INFO","message":"server listening on :8080"}
```
> You can also run directly with `go run ./cmd/diy/main.go`, passing flags or relying on environment variables / config file as described in [Step 3](#step-3---configuration).
### 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](https://curl.se/) GET request to call `ping`:
```bash
$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/ping' \
--header 'x-auth-provider: google' \
--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>'
{"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. 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](#step-2---authentication-and-authorization), you can generate a new token at the [Google OAuth2 Playground](https://developers.google.com/oauthplayground/)
**Create Movie** - use the `POST` HTTP verb at `/api/v1/movies`:
```shell
$ 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.
```bash
$ 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`:
```bash
$ 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.
```bash
$ 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.
```bash
$ 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

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 Server's standard library multiplexer (aka ServeMux, initialization of which, is part of server startup in the `cmd` 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](https://github.com/benbjohnson/wtf) and [accompanying blog](https://www.gobeyond.dev/) from [Ben Johnson](https://github.com/benbjohnson). 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](https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html) 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:
```json
{
"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:
```json
{
"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 [errs](https://github.com/gilcrest/diygoapi/tree/main/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:
```go
// 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 [errs](https://github.com/gilcrest/diygoapi/tree/main/errs) package. `errs.E` is taken from Rob Pike's [upspin errors package](https://github.com/upspin/upspin/tree/master/errors) (but has been changed based on my requirements). The `errs.E` function call is [variadic](https://en.wikipedia.org/wiki/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`:
```go
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.
```go
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:
```go
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:
```go
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](https://medium.com/@matryer/patterns-for-decoding-and-validating-input-in-go-data-apis-152291ac7372) and is pretty handy.
Here is an example in practice:
```go
// 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:
```go
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](https://github.com/gilcrest/diygoapi/blob/main/server/handlers.go)). 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:
```go
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.
```go
// 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:
```json
{
"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.
```json
{
"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*):
```json
{
"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:
```json
{
"error": {
"kind": "internal_error",
"message": "internal server error - please contact support"
}
}
```
--------
#### Unauthenticated Errors
The [spec](https://tools.ietf.org/html/rfc7235#section-3.1) 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.
```go
// 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](https://curl.se/) looks like the following:
```bash
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.
```go
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](https://curl.se/) looks like the following:
```bash
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](https://github.com/rs/zerolog) library from [Olivier Poitrey](https://github.com/rs). The mechanics for using `zerolog` are straightforward and are well documented in the library's [README](https://github.com/rs/zerolog#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 | info |
| log-level-min | sets the minimum accepted logging level | LOG_LEVEL_MIN | trace |
| 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 in [Step 3](#step-3---configuration), `diygoapi` uses the [ff](https://github.com/peterbourgon/ff) library from [Peter Bourgon](https://peter.bourgon.org), which allows for using flags, environment variables, or a config file. 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](https://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 `cmd/cmd.go`
```go
// setup logger with appropriate defaults
lgr := logger.NewWithGCPHook(os.Stdout, minlvl, true)
```
and subsequently used to initialize the `server.Server` struct.
```go
// initialize Server enfolding a http.Server with default timeouts,
// a mux router and a zerolog.Logger
s := server.New(http.NewServeMux(), server.NewDriver(), lgr)
```
#### Logger Setup in Handlers
The `Server.registerRoutes` method is responsible for registering routes and corresponding middleware/handlers to the Server's multiplexer (aka 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.
```go
// register routes/middleware/handlers to the Server ServeMux
func (s *Server) registerRoutes() {
// Match only POST requests at /api/v1/movies
// with Content-Type header = application/json
s.mux.Handle("POST /api/v1/movies",
s.loggerChain().
Append(s.addRequestHandlerPatternContextHandler).
Append(s.enforceJSONContentTypeHandler).
Append(s.appHandler).
Append(s.authHandler).
Append(s.authorizeUserHandler).
Append(s.jsonContentTypeResponseHandler).
ThenFunc(s.handleMovieCreate))
...
```
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.
```go
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:
```json
{
"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:
```json
{
"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:
```bash
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:
```json
{
"logger_minimum_level": "debug",
"global_log_level": "error",
"log_error_stack": false
}
```
In order to update the logger state use a `PUT` request:
```bash
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:
```json
{
"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.
#### Authentication Detail
**Authentication** is determined by validating the `App` making the request as well as the `User`.
##### App Authentication Detail
An `App` (aka API Client) can be registered using the `POST /api/v1/apps` service. The `App` can be registered with an association to an Oauth2 Provider Client ID or be standalone.
An `App` has two possible methods of authentication.
1. The first method, which overrides the second, is using the `X-APP-ID` and `X-API-KEY` HTTP headers. The `X-APP-ID` is the app unique identifier and the `X-API-KEY` is the password. This method confirms the veracity of the App against values stored in the database (the password is encrypted in the db). If the App ID is not found or the API key does not match the stored API key, an `HTTP 401 (Unauthorized)` response will be sent and the response body will be empty. If the authentication is successful, the App details will be set to the request context for downstream use.
2. If there is no X-APP-ID header present, the second method is using the authorization Provider's Client ID associated with the `Authorization` header Bearer token. When a request is made only using the `Authorization` header, a callback to the provider's Oauth2 TokenInfo API is done to retrieve the associated Provider Client ID. The Provider Client ID is then used to find the associated App in the database. If the App is not found, an `HTTP 401 (Unauthorized)` response will be sent and the response body will be empty. If the App is found, the App details will be set to the request context for downstream use.
##### User Authentication Detail
User authentication happens outside the API using an Oauth2 provider (e.g. Google, Github, etc.) sign-in page. After successful authentication, the user is given a Bearer token which is then used for service-level authentication.
In order to perform any actions with the `diygoapi` services, a User must Self-Register. The `SelfRegister` service creates a Person/User and stores them in the database. In addition, an `Auth` object, which represents the user's credentials is stored in the db. A search is done prior to `Auth` creation to determine if the user is already registered, and if so, the existing user is returned.
After a user is registered, they can perform actions (use resources/endpoints) using the services. For every call, the `Authorization` HTTP header with the user's Bearer token along with the `X-AUTH-PROVIDER` header used to denote the `Provider` (e.g. Google, Github, etc.) must be set.
The Bearer token is used to find the `Auth` object for the user in the database. Searching for the `Auth` object is done as follows:
- Search the database directly using the Bearer token.
- If an `Auth` object already exists in the datastore which matches the `Bearer` token and the `Bearer` token is not past its expiration date, the existing `Auth` will be used to determine the User.
- If no `Auth` object exists in the datastore for the request `Bearer` token, an attempt will be made to find the user's `Auth` with the Provider ID (e.g. Google) and unique person ID given by the provider (found by calling the provider API with the request `Bearer` token). If an `Auth` object exists given these attributes, it will be updated with the new `Bearer` token details and this `Auth` will be used to obtain the `User` details.
If the `Auth` object is not found, an `HTTP 401 (Unauthorized)` response will be sent and the response body will be empty. If the `Auth` object is found, the `User` details will be set to the request context for downstream use.
#### Authorization Detail
After a user is authenticated, their ability to access a given resource is determined by **authorization**. `diygoapi` implements a custom, database-driven Role Based Access Control (RBAC) model. Authorization is enforced via the `authorizeUserHandler` middleware, which runs after authentication in the middleware chain for every protected route:
```go
s.mux.Handle("POST /api/v1/movies",
s.loggerChain().
Append(s.addRequestHandlerPatternContextHandler).
Append(s.enforceJSONContentTypeHandler).
Append(s.appHandler). // authenticate app
Append(s.authHandler). // authenticate user
Append(s.authorizeUserHandler). // authorize user (RBAC)
Append(s.jsonContentTypeResponseHandler).
ThenFunc(s.handleMovieCreate))
```
The `authorizeUserHandler` middleware retrieves the authenticated `User` and `App` from the request context (set by earlier authentication middleware), then delegates to `DBAuthorizationService.Authorize`. This method extracts the handler pattern (e.g. `POST /api/v1/movies`) from the request context and splits it into the HTTP method (`POST`) and the resource path (`/api/v1/movies`). It then executes a database query to determine if the user is authorized.
If the user is not authorized, an `HTTP 403 (Forbidden)` response is returned with an empty response body.
##### Role Based Access Control
The RBAC model is built on three core domain types, defined in the root package (`auth.go`):
- **Permission**: An approval of a mode of access to a resource. Each permission pairs a `Resource` (an HTTP route, e.g. `/api/v1/movies`) with an `Operation` (an HTTP method, e.g. `POST`). Permissions have an `Active` flag — only active permissions grant access.
- **Role**: A job function or title which defines an authority level (e.g. `movieAdmin`). Each role has a `Code`, a `Description`, and a list of `Permissions`.
- **User**: Roles are assigned at the User level (not the Person level), allowing for fine-grained access control across personas.
These are linked together through four database tables:
| Table | Purpose |
|---|---|
| `permission` | Stores each permission as a unique `(resource, operation)` pair |
| `role` | Stores roles with a unique `role_cd` |
| `role_permission` | Junction table linking roles to their permissions |
| `users_role` | Junction table linking users to roles, **scoped by organization** (`org_id`) |
The `users_role` table's composite primary key of `(user_id, role_id, org_id)` means a user's roles are scoped per organization, enabling multi-tenant access control.
###### Authorization Query
The authorization check is a single SQL query (`IsAuthorized`) that joins through the RBAC chain:
```sql
SELECT ur.user_id
FROM users_role ur
INNER JOIN role_permission rp on rp.role_id = ur.role_id
INNER JOIN permission p on p.permission_id = rp.permission_id
WHERE p.active = true
AND p.resource = $1 -- e.g. '/api/v1/movies'
AND p.operation = $2 -- e.g. 'POST'
AND ur.user_id = $3 -- authenticated user
AND ur.org_id = $4; -- org from authenticated app
```
The organization context (`org_id`) is derived from the authenticated `App` — each app belongs to exactly one `Org`. This means authorization is determined by: _does this user have a role (within this app's organization) that includes a permission matching the requested resource and HTTP method?_
For example, when a user calls `POST /api/v1/movies`, the query checks: does `users_role` contain a row for this user and org? Does that role have a `role_permission` entry linking to a `permission` where `resource = '/api/v1/movies'` and `operation = 'POST'`? If so, the user is authorized.
If the query returns a matching `user_id`, the request proceeds to the handler. If not, the middleware returns an `HTTP 403 (Forbidden)` response with an empty body and logs the unauthorized attempt.
> Note: For details on the 403 response format, see the [Unauthorized Errors](#unauthorized-errors) section above.
================================================
FILE: Taskfile.yml
================================================
version: '3'
includes:
cue:
taskfile: ./config/cue/Taskfile.yml
dir: ./config/cue
tasks:
db-init:
desc: Initialize database user, database, and schema via psql
cmds:
- go run ./cmd/dbinit/main.go {{.CLI_ARGS}}
db-teardown:
desc: Drop database schema, database, and user via psql
cmds:
- go run ./cmd/dbteardown/main.go {{.CLI_ARGS}}
db-up:
desc: Run DDL up migration scripts found in the scripts/db/migrations/up directory
cmds:
- go run ./cmd/migrations/main.go {{.CLI_ARGS}}
run:
desc: Run the server
cmds:
- go run ./cmd/diy/main.go
test:
desc: Run all tests
cmds:
- go test ./...
test-verbose:
desc: Run all tests (verbose)
cmds:
- go test -v ./...
new-key:
desc: Generate a new encryption key
cmds:
- go run ./cmd/newkey/main.go
gcp-deploy:
desc: Build and deploy to GCP Cloud Run (not yet implemented)
cmds:
- echo "Not yet implemented — GCP deployment helpers need to be redesigned"
- exit 1
gcp-db-start:
desc: Start GCP Cloud SQL instance (not yet implemented)
cmds:
- echo "Not yet implemented"
- exit 1
gcp-db-stop:
desc: Stop GCP Cloud SQL instance (not yet implemented)
cmds:
- echo "Not yet implemented"
- exit 1
gen-config:
desc: Generate config from CUE schemas
cmds:
- task: cue:gen-config
gen-genesis-config:
desc: Generate genesis config from CUE files (not yet implemented)
cmds:
- echo "Not yet implemented — CUEGenesisPaths helper was removed"
- exit 1
================================================
FILE: app.go
================================================
package diygoapi
import (
"context"
"encoding/hex"
"fmt"
"time"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
// AppServicer manages the retrieval and manipulation of an App
type AppServicer interface {
Create(ctx context.Context, r *CreateAppRequest, adt Audit) (*AppResponse, error)
Update(ctx context.Context, r *UpdateAppRequest, adt Audit) (*AppResponse, error)
}
// APIKeyGenerator creates a random, 128 API key string
type APIKeyGenerator interface {
RandomString(n int) (string, error)
}
// App is an application that interacts with the system
type App struct {
ID uuid.UUID
ExternalID secure.Identifier
Org *Org
Name string
Description string
Provider Provider
ProviderClientID string
APIKeys []APIKey
}
// AddKey validates and adds an API key to the slice of App API keys
func (a *App) AddKey(key APIKey) error {
const op errs.Op = "diygoapi/App.AddKey"
err := key.validate()
if err != nil {
return errs.E(op, errs.Internal, err)
}
a.APIKeys = append(a.APIKeys, key)
return nil
}
// ValidateKey determines if the app has a matching key for the input
// and if that key is valid
func (a *App) ValidateKey(realm, matchKey string) error {
const op errs.Op = "diygoapi/App.ValidateKey"
key, err := a.matchKey(realm, matchKey)
if err != nil {
return err
}
err = key.validate()
if err != nil {
return errs.E(op, errs.Unauthenticated, errs.Realm(realm), err)
}
return nil
}
// MatchKey returns the matching Key given the string, if exists.
// An error will be sent if no match is found.
func (a *App) matchKey(realm, matchKey string) (APIKey, error) {
const op errs.Op = "diygoapi/App.matchKey"
for _, apiKey := range a.APIKeys {
if matchKey == apiKey.Key() {
return apiKey, nil
}
}
return APIKey{}, errs.E(op, errs.Unauthenticated, errs.Realm(realm), "Key does not match any keys for the App")
}
// CreateAppRequest is the request struct for Creating an App
type CreateAppRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Oauth2Provider string `json:"oauth2_provider"`
Oauth2ProviderClientID string `json:"oauth2_provider_client_id"`
}
// Validate determines whether the CreateAppRequest has proper data to be considered valid
func (r CreateAppRequest) Validate() error {
const op errs.Op = "diygoapi/CreateAppRequest.Validate"
switch {
case r.Name == "":
return errs.E(op, errs.Validation, "app name is required")
case r.Description == "":
return errs.E(op, errs.Validation, "app description is required")
case r.Oauth2Provider == "" && r.Oauth2ProviderClientID != "":
return errs.E(op, errs.Validation, "oAuth2 provider is required when Oauth2 provider client ID is given")
case r.Oauth2Provider != "" && r.Oauth2ProviderClientID == "":
return errs.E(op, errs.Validation, "oAuth2 provider client ID is required when Oauth2 provider is given")
case r.Oauth2Provider != "" && r.Oauth2ProviderClientID == "REPLACE_ME":
return errs.E(op, errs.Validation, "oAuth2 provider client ID cannot be REPLACE_ME. ")
}
// check if the provider is a known provider
p := ParseProvider(r.Oauth2Provider)
// if the provider is unknown, return an error
if p == UnknownProvider {
return errs.E(op, errs.Validation, "Unknown OAuth2 Provider")
}
return nil
}
// UpdateAppRequest is the request struct for Updating an App
type UpdateAppRequest struct {
ExternalID string
Name string `json:"name"`
Description string `json:"description"`
}
// AppResponse is the response struct for an App
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"`
}
// APIKeyResponse is the response fields for an API key
type APIKeyResponse struct {
Key string `json:"key"`
DeactivationDate string `json:"deactivation_date"`
}
// 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.
type APIKey struct {
// key: the unencrypted API key string
key string
// ciphertext: the encrypted API key as []byte
ciphertextbytes []byte
// deactivation: the date/time the API key is no longer usable
deactivation time.Time
}
// 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 NewAPIKey(g APIKeyGenerator, ek *[32]byte, deactivation time.Time) (APIKey, error) {
const (
n int = 16
op = "diygoapi/NewAPIKey"
)
var (
k string
err error
)
k, err = g.RandomString(n)
if err != nil {
return APIKey{}, errs.E(op, err)
}
var ctb []byte
ctb, err = secure.Encrypt([]byte(k), ek)
if err != nil {
return APIKey{}, err
}
return APIKey{key: k, ciphertextbytes: ctb, deactivation: deactivation}, nil
}
// NewAPIKeyFromCipher initializes an APIKey given a ciphertext string.
func NewAPIKeyFromCipher(ciphertext string, ek *[32]byte) (APIKey, error) {
const op errs.Op = "diygoapi/NewAPIKeyFromCipher"
var (
eak []byte
err error
)
// encrypted api key is stored using hex in db. Decode to get ciphertext bytes.
eak, err = hex.DecodeString(ciphertext)
if err != nil {
return APIKey{}, errs.E(op, errs.Internal, err)
}
var apiKey []byte
apiKey, err = secure.Decrypt(eak, ek)
if err != nil {
return APIKey{}, errs.E(op, err)
}
return APIKey{key: string(apiKey), ciphertextbytes: eak}, nil
}
// Key returns the key for the API key
func (a *APIKey) Key() string {
return a.key
}
// Ciphertext returns the hex encoded text of the encrypted cipher bytes for the API key
func (a *APIKey) Ciphertext() string {
return hex.EncodeToString(a.ciphertextbytes)
}
// DeactivationDate returns the Deactivation Date for the API key
func (a *APIKey) DeactivationDate() time.Time {
return a.deactivation
}
// SetDeactivationDate sets the deactivation date value to AppAPIkey
// TODO - try SetDeactivationDate as a candidate for generics with 1.18
func (a *APIKey) SetDeactivationDate(t time.Time) {
a.deactivation = t
}
// SetStringAsDeactivationDate sets the deactivation date value to
// AppAPIkey given a string in RFC3339 format
func (a *APIKey) SetStringAsDeactivationDate(s string) error {
const op errs.Op = "diygoapi/APIKey.SetStringAsDeactivationDate"
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return errs.E(op, errs.Validation, err)
}
a.deactivation = t
return nil
}
func (a *APIKey) validate() error {
const op errs.Op = "diygoapi/APIKey.validate"
if a.ciphertextbytes == nil {
return errs.E(op, "ciphertext must have a value")
}
now := time.Now()
if a.deactivation.Before(now) {
return errs.E(op, fmt.Sprintf("Key Deactivation %s is before current time %s", a.deactivation.String(), now.String()))
}
return nil
}
================================================
FILE: app_test.go
================================================
package diygoapi_test
import (
"encoding/base64"
"encoding/hex"
"fmt"
"testing"
"time"
qt "github.com/frankban/quicktest"
"github.com/gilcrest/diygoapi"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
func TestApp_AddKey(t *testing.T) {
t.Run("add valid key", func(t *testing.T) {
c := qt.New(t)
o := &diygoapi.Org{
ID: uuid.New(),
ExternalID: secure.NewID(),
Name: "app test",
Description: "test app",
Kind: &diygoapi.OrgKind{},
}
a := diygoapi.App{
ID: uuid.New(),
ExternalID: secure.NewID(),
Org: o,
Name: "",
Description: "",
APIKeys: nil,
}
var (
ek *[32]byte
err error
)
ek, err = secure.NewEncryptionKey()
c.Assert(err, qt.IsNil)
var key diygoapi.APIKey
key, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*100))
c.Assert(err, qt.IsNil)
err = a.AddKey(key)
c.Assert(err, qt.IsNil)
c.Assert(len(a.APIKeys), qt.Equals, 1)
})
t.Run("add expired key", func(t *testing.T) {
c := qt.New(t)
o := &diygoapi.Org{
ID: uuid.New(),
ExternalID: secure.NewID(),
Name: "app test",
Description: "test app",
Kind: &diygoapi.OrgKind{},
}
a := diygoapi.App{
ID: uuid.New(),
ExternalID: secure.NewID(),
Org: o,
Name: "",
Description: "",
APIKeys: nil,
}
var (
ek *[32]byte
err error
)
ek, err = secure.NewEncryptionKey()
c.Assert(err, qt.IsNil)
var key diygoapi.APIKey
key, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*-100))
c.Assert(err, qt.IsNil)
err = a.AddKey(key)
c.Assert(err, qt.Not(qt.IsNil))
})
}
func TestApp_ValidateKey(t *testing.T) {
t.Run("valid key", func(t *testing.T) {
c := qt.New(t)
o := &diygoapi.Org{
ID: uuid.New(),
ExternalID: secure.NewID(),
Name: "app test",
Description: "test app",
Kind: &diygoapi.OrgKind{},
}
a := diygoapi.App{
ID: uuid.New(),
ExternalID: secure.NewID(),
Org: o,
Name: "",
Description: "",
APIKeys: nil,
}
var (
ek *[32]byte
err error
)
ek, err = secure.NewEncryptionKey()
c.Assert(err, qt.IsNil)
var key diygoapi.APIKey
key, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*100))
c.Assert(err, qt.IsNil)
err = a.AddKey(key)
c.Assert(err, qt.IsNil)
err = a.ValidateKey("deep in the realm", key.Key())
c.Assert(err, qt.IsNil)
})
t.Run("key does not match", func(t *testing.T) {
c := qt.New(t)
o := &diygoapi.Org{
ID: uuid.New(),
ExternalID: secure.NewID(),
Name: "app test",
Description: "test app",
Kind: &diygoapi.OrgKind{},
}
a := diygoapi.App{
ID: uuid.New(),
ExternalID: secure.NewID(),
Org: o,
Name: "",
Description: "",
APIKeys: nil,
}
err := a.ValidateKey("deep in the realm", "badkey")
c.Assert(err, qt.ErrorMatches, "Key does not match any keys for the App")
})
t.Run("key matches but invalid", func(t *testing.T) {
c := qt.New(t)
o := &diygoapi.Org{
ID: uuid.New(),
ExternalID: secure.NewID(),
Name: "app test",
Description: "test app",
Kind: &diygoapi.OrgKind{},
}
a := diygoapi.App{
ID: uuid.New(),
ExternalID: secure.NewID(),
Org: o,
Name: "",
Description: "",
APIKeys: nil,
}
var (
ek *[32]byte
err error
)
ek, err = secure.NewEncryptionKey()
c.Assert(err, qt.IsNil)
var key diygoapi.APIKey
key, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*-100))
c.Assert(err, qt.IsNil)
a.APIKeys = append(a.APIKeys, key)
err = a.ValidateKey("deep in the realm", key.Key())
c.Assert(err, qt.ErrorMatches, fmt.Sprintf("Key Deactivation %s is before current time .*", key.DeactivationDate().String()))
})
}
func TestNewAPIKey(t *testing.T) {
t.Run("key byte length", func(t *testing.T) {
c := qt.New(t)
var (
ek *[32]byte
err error
)
ek, err = secure.NewEncryptionKey()
c.Assert(err, qt.IsNil)
var key diygoapi.APIKey
key, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Date(2999, 12, 31, 0, 0, 0, 0, time.UTC))
c.Assert(err, qt.IsNil)
// decode base64
var keyBytes []byte
keyBytes, err = base64.URLEncoding.DecodeString(key.Key())
c.Assert(err, qt.IsNil)
c.Assert(len(keyBytes), qt.Equals, 16, qt.Commentf("assure key byte length is always 16 (128-bit)"))
})
t.Run("decrypt key", func(t *testing.T) {
c := qt.New(t)
var (
ek *[32]byte
err error
)
ek, err = secure.NewEncryptionKey()
c.Assert(err, qt.IsNil)
var key diygoapi.APIKey
key, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Date(2999, 12, 31, 0, 0, 0, 0, time.UTC))
c.Assert(err, qt.IsNil)
// Ciphertext method returns the bytes as a hex encoded string.
// decode to get the bytes
var cb []byte
cb, err = hex.DecodeString(key.Ciphertext())
c.Assert(err, qt.IsNil)
// decrypt the encrypted key
var apiKey []byte
apiKey, err = secure.Decrypt(cb, ek)
c.Assert(err, qt.IsNil)
c.Assert(string(apiKey), qt.Equals, key.Key(), qt.Commentf("ensure decrypted key matches key string"))
})
}
================================================
FILE: auth.go
================================================
package diygoapi
import (
"context"
"net/http"
"strings"
"time"
"github.com/rs/zerolog"
"golang.org/x/oauth2"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
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"
)
// PermissionServicer allows for creating, updating, reading and deleting a Permission
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)
}
// RoleServicer allows for creating, updating, reading and deleting a Role
// as well as assigning permissions and users to it.
type RoleServicer interface {
Create(ctx context.Context, r *CreateRoleRequest, adt Audit) (*RoleResponse, 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 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)
}
// AuthorizationServicer represents a service for managing authorization.
type AuthorizationServicer interface {
Authorize(r *http.Request, lgr zerolog.Logger, adt Audit) error
}
// TokenExchanger exchanges an oauth2.Token for a ProviderUserInfo
// struct populated with information retrieved from an authentication provider.
type TokenExchanger interface {
Exchange(ctx context.Context, realm string, provider Provider, token *oauth2.Token) (*ProviderInfo, error)
}
// BearerTokenType is used in authorization to access a resource
const BearerTokenType string = "Bearer"
// Provider defines the provider of authorization (Google, GitHub, Apple, auth0, etc.).
//
// Only Google is used currently.
type Provider uint8
// Provider of authorization
//
// The app uses Oauth2 to authorize users with one of the following Providers
const (
UnknownProvider Provider = iota
Google // Google
)
func (p Provider) String() string {
switch p {
case Google:
return "google"
default:
return "unknown_provider"
}
}
// ParseProvider initializes a Provider given a case-insensitive string
func ParseProvider(s string) Provider {
switch strings.ToLower(s) {
case "google":
return Google
}
return UnknownProvider
}
// ProviderInfo contains information returned from an authorization provider
type ProviderInfo struct {
Provider Provider
TokenInfo *ProviderTokenInfo
UserInfo *ProviderUserInfo
}
// 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 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"`
}
// ProviderUserInfo contains common fields from the various Oauth2 providers.
// Currently only using Google, so looks a lot like Google's.
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
}
// 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 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
}
// Permission stores an approval of a mode of access to a resource.
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
}
// Validate determines if the Permission is valid
func (p Permission) Validate() error {
const op errs.Op = "diygoapi/Permission.Validate"
switch {
case p.ID == uuid.Nil:
return errs.E(op, errs.Validation, "ID is required")
case p.ExternalID.String() == "":
return errs.E(op, errs.Validation, "External ID is required")
case p.Resource == "":
return errs.E(op, errs.Validation, "Resource is required")
case p.Description == "":
return errs.E(op, errs.Validation, "Description is required")
}
return nil
}
// CreatePermissionRequest is the request struct for creating a permission
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"`
}
// FindPermissionRequest is the response struct for finding a permission
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"`
}
// PermissionResponse is the response struct for a permission
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"`
}
// Role is a job function or title which defines an authority level.
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
}
// Validate determines if the Role is valid.
func (r Role) Validate() error {
const op errs.Op = "diygoapi/Role.Validate"
switch {
case r.ID == uuid.Nil:
return errs.E(op, errs.Validation, "ID is required")
case r.ExternalID.String() == "":
return errs.E(op, errs.Validation, "External ID is required")
case r.Code == "":
return errs.E(op, errs.Validation, "Code is required")
case r.Description == "":
return errs.E(op, errs.Validation, "Description is required")
}
return nil
}
// CreateRoleRequest is the request struct for creating a role
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
}
// RoleResponse is the response struct for a Role.
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
}
// AuthenticationParams is the parameters needed for authenticating a User.
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
}
================================================
FILE: auth_test.go
================================================
package diygoapi_test
import (
"github.com/gilcrest/diygoapi"
"testing"
qt "github.com/frankban/quicktest"
)
func TestNewProvider(t *testing.T) {
t.Run("google", func(t *testing.T) {
c := qt.New(t)
p := diygoapi.ParseProvider("GoOgLe")
c.Assert(p, qt.Equals, diygoapi.Google)
})
t.Run("unknown", func(t *testing.T) {
c := qt.New(t)
p := diygoapi.ParseProvider("anything else!")
c.Assert(p, qt.Equals, diygoapi.UnknownProvider)
})
}
func TestProvider_String(t *testing.T) {
t.Run("google", func(t *testing.T) {
c := qt.New(t)
p := diygoapi.ParseProvider("GoOgLe")
provider := p.String()
c.Assert(provider, qt.Equals, "google")
})
t.Run("unknown", func(t *testing.T) {
c := qt.New(t)
p := diygoapi.ParseProvider("anything else")
provider := p.String()
c.Assert(provider, qt.Equals, "unknown_provider")
})
}
================================================
FILE: context.go
================================================
package diygoapi
import (
"context"
"net/http"
"time"
"github.com/gilcrest/diygoapi/errs"
)
type contextKey string
const (
handlerPatternKey contextKey = "handlerPattern"
appContextKey contextKey = "app"
contextKeyUser contextKey = "user"
authParamsContextKey contextKey = "authParams"
)
// NewContextWithRequestHandlerPattern returns a new context with the given Handler pattern
func NewContextWithRequestHandlerPattern(ctx context.Context, pattern string) context.Context {
return context.WithValue(ctx, handlerPatternKey, pattern)
}
// HandlerPatternFromRequest is a helper function which returns the handler pattern from the
// request context.
func HandlerPatternFromRequest(r *http.Request) (string, error) {
const op errs.Op = "diygoapi/HandlerPatternFromRequest"
pattern, err := RequestHandlerPatternFromContext(r.Context())
if err != nil {
return "", errs.E(op, err)
}
return pattern, nil
}
// RequestHandlerPatternFromContext returns a Handler Pattern from the given context
func RequestHandlerPatternFromContext(ctx context.Context) (string, error) {
const op errs.Op = "diygoapi/RequestHandlerPatternFromContext"
pattern, ok := ctx.Value(handlerPatternKey).(string)
if !ok {
return "", errs.E(op, errs.NotExist, "handler pattern not set to context")
}
if pattern == "" {
return "", errs.E(op, errs.NotExist, "handler pattern not set to context (empty string)")
}
return pattern, nil
}
// NewContextWithApp returns a new context with the given App
func NewContextWithApp(ctx context.Context, a *App) context.Context {
return context.WithValue(ctx, appContextKey, a)
}
// AppFromRequest is a helper function which returns the App from the
// request context.
func AppFromRequest(r *http.Request) (*App, error) {
const op errs.Op = "diygoapi/AppFromRequest"
app, err := AppFromContext(r.Context())
if err != nil {
return nil, errs.E(op, err)
}
return app, nil
}
// AppFromContext returns the App from the given context
func AppFromContext(ctx context.Context) (*App, error) {
const op errs.Op = "diygoapi/AppFromContext"
a, ok := ctx.Value(appContextKey).(*App)
if !ok {
return a, errs.E(op, errs.NotExist, "App not set to context")
}
return a, nil
}
// NewContextWithUser returns a new context with the given User
func NewContextWithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, contextKeyUser, u)
}
// UserFromRequest returns the User from the request context
func UserFromRequest(r *http.Request) (u *User, err error) {
const op errs.Op = "diygoapi/UserFromRequest"
u, err = UserFromContext(r.Context())
if err != nil {
return nil, errs.E(op, err)
}
return u, nil
}
// UserFromContext returns the User from the given Context
func UserFromContext(ctx context.Context) (*User, error) {
const op errs.Op = "diygoapi/UserFromContext"
u, ok := ctx.Value(contextKeyUser).(*User)
if !ok {
return nil, errs.E(op, errs.NotExist, "User not set properly to context")
}
return u, nil
}
// 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
func AuditFromRequest(r *http.Request) (adt Audit, err error) {
const op errs.Op = "diygoapi/AuditFromRequest"
var a *App
a, err = AppFromRequest(r)
if err != nil {
return Audit{}, errs.E(op, err)
}
var u *User
u, err = UserFromRequest(r)
if err != nil {
return Audit{}, errs.E(op, err)
}
adt.App = a
adt.User = u
adt.Moment = time.Now()
return adt, nil
}
// NewContextWithAuthParams returns a new context with the given AuthenticationParams
func NewContextWithAuthParams(ctx context.Context, ap *AuthenticationParams) context.Context {
return context.WithValue(ctx, authParamsContextKey, ap)
}
// AuthParamsFromContext returns the AuthenticationParams from the given context
func AuthParamsFromContext(ctx context.Context) (*AuthenticationParams, error) {
const op errs.Op = "diygoapi/AuthParamsFromContext"
a, ok := ctx.Value(authParamsContextKey).(*AuthenticationParams)
if !ok {
return a, errs.E(op, errs.NotExist, "Authentication Params not set to context")
}
return a, nil
}
================================================
FILE: context_test.go
================================================
package diygoapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
qt "github.com/frankban/quicktest"
"github.com/google/go-cmp/cmp"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
func TestUserFromRequest(t *testing.T) {
t.Run("typical", func(t *testing.T) {
c := qt.New(t)
r := httptest.NewRequest(http.MethodGet, "/api/v1/movies", nil)
want := &User{
ID: uuid.New(),
ExternalID: secure.NewID(),
NamePrefix: "",
FirstName: "Otto",
MiddleName: "",
LastName: "Maddox",
FullName: "Otto Maddox",
NameSuffix: "",
Nickname: "",
Email: "otto.maddox@helpinghandacceptanceco.com",
CompanyName: "",
CompanyDepartment: "",
JobTitle: "",
BirthDate: time.Time{},
LanguagePreferences: nil,
HostedDomain: "",
PictureURL: "",
ProfileLink: "",
Source: "",
}
ctx := NewContextWithUser(context.Background(), want)
r = r.WithContext(ctx)
got, err := UserFromRequest(r)
c.Assert(err, qt.IsNil)
c.Assert(got, qt.DeepEquals, want)
})
t.Run("no person added to Request context", func(t *testing.T) {
c := qt.New(t)
r := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil)
const op1 errs.Op = "diygoapi/UserFromContext"
const op2 errs.Op = "diygoapi/UserFromRequest"
ctxErr := errs.E(op1, errs.NotExist, "User not set properly to context")
wantErr := errs.E(op2, ctxErr)
u, err := UserFromRequest(r)
c.Assert(err, qt.CmpEquals(cmp.Comparer(errs.Match)), wantErr)
c.Assert(u, qt.IsNil)
})
}
================================================
FILE: db.go
================================================
package diygoapi
import (
"context"
"time"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog"
)
// Datastorer is an interface for working with the Database
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
}
// DBTX interface mirrors the interface generated by https://github.com/kyleconroy/sqlc
// to allow passing a Pool or a Tx
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
}
// PingServicer pings the database and responds whether it is up or down
type PingServicer interface {
Ping(ctx context.Context, lgr zerolog.Logger) PingResponse
}
// PingResponse is the response struct for the PingService
type PingResponse struct {
DBUp bool `json:"db_up"`
}
// NewPgxInt4 returns a pgx/pgtype.Int4 with the input value
func NewPgxInt4(i int32) pgtype.Int4 {
return pgtype.Int4{
Int32: i,
Valid: true,
}
}
// NewPgxInt8 returns a pgx/pgtype.Int8 with the input value
func NewPgxInt8(i int64) pgtype.Int8 {
return pgtype.Int8{
Int64: i,
Valid: true,
}
}
// NewPgxText returns a pgx/pgtype.Text with the input value.
// If the input string is empty, it returns an empty pgtype.Text
func NewPgxText(s string) pgtype.Text {
if len(s) == 0 {
return pgtype.Text{}
}
return pgtype.Text{
String: s,
Valid: true,
}
}
// NewPgxTimestampTZ returns a pgx/pgtype.Timestamptz with the input value
func NewPgxTimestampTZ(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{
Time: t,
Valid: true,
}
}
// NewPgxDate returns a pgx/pgtype.Date with the input value
func NewPgxDate(t time.Time) pgtype.Date {
return pgtype.Date{
Time: t,
Valid: true,
}
}
================================================
FILE: diygoapi.go
================================================
// Package diygoapi comprises application or business domain data types and functions.
package diygoapi
import (
"context"
"time"
)
// LoggerServicer reads and updates the logger state
type LoggerServicer interface {
Read() *LoggerResponse
Update(r *LoggerRequest) (*LoggerResponse, error)
}
// GenesisServicer initializes the database with dependent data
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)
}
// Audit represents the moment an App/User interacted with the system.
type Audit struct {
App *App
User *User
Moment time.Time
}
// 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 SimpleAudit struct {
Create Audit `json:"create"`
Update Audit `json:"update"`
}
// DeleteResponse is the general response struct for things
// which have been deleted
type DeleteResponse struct {
ExternalID string `json:"extl_id"`
Deleted bool `json:"deleted"`
}
// LoggerRequest is the request struct for the app logger
type LoggerRequest struct {
GlobalLogLevel string `json:"global_log_level"`
LogErrorStack string `json:"log_error_stack"`
}
// LoggerResponse is the response struct for the current
// state of the app logger
type LoggerResponse struct {
LoggerMinimumLevel string `json:"logger_minimum_level"`
GlobalLogLevel string `json:"global_log_level"`
LogErrorStack bool `json:"log_error_stack"`
}
// GenesisRequest is the request struct for the genesis service
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"`
}
// GenesisResponse contains both the Genesis response and the Test response
type GenesisResponse struct {
Principal *OrgResponse `json:"principal"`
Test *OrgResponse `json:"test"`
UserInitiated *OrgResponse `json:"userInitiated,omitempty"`
}
================================================
FILE: go.mod
================================================
module github.com/gilcrest/diygoapi
go 1.26
require (
github.com/frankban/quicktest v1.14.6
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/jackc/pgconn v1.14.3
github.com/jackc/pgx/v5 v5.8.0
github.com/justinas/alice v1.2.0
github.com/peterbourgon/ff/v3 v3.4.0
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.34.0
golang.org/x/oauth2 v0.35.0
golang.org/x/text v0.34.0
google.golang.org/api v0.267.0
)
require (
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
================================================
FILE: go.sum
================================================
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: movie.go
================================================
package diygoapi
import (
"context"
"time"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
// MovieServicer is used to create, read, update and delete movies.
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)
}
// Movie holds details of a movie
type Movie struct {
ID uuid.UUID
ExternalID secure.Identifier
Title string
Rated string
Released time.Time
RunTime int64
Director string
Writer string
}
// IsValid performs validation of the struct
func (m *Movie) IsValid() error {
const op errs.Op = "diygoapi/Movie.IsValid"
switch {
case m.ExternalID.String() == "":
return errs.E(op, errs.Validation, errs.Parameter("extlID"), errs.MissingField("extlID"))
case m.Title == "":
return errs.E(op, errs.Validation, errs.Parameter("title"), errs.MissingField("title"))
case m.Rated == "":
return errs.E(op, errs.Validation, errs.Parameter("rated"), errs.MissingField("rated"))
case m.Released.IsZero():
return errs.E(op, errs.Validation, errs.Parameter("release_date"), "release_date must have a value")
case m.RunTime <= 0:
return errs.E(op, errs.Validation, errs.Parameter("run_time"), "run_time must be greater than zero")
case m.Director == "":
return errs.E(op, errs.Validation, errs.Parameter("director"), errs.MissingField("director"))
case m.Writer == "":
return errs.E(op, errs.Validation, errs.Parameter("writer"), errs.MissingField("writer"))
}
return nil
}
// CreateMovieRequest is the request struct for Creating a Movie
type CreateMovieRequest struct {
Title string `json:"title"`
Rated string `json:"rated"`
Released string `json:"release_date"`
RunTime int64 `json:"run_time"`
Director string `json:"director"`
Writer string `json:"writer"`
}
// UpdateMovieRequest is the request struct for updating a Movie
type UpdateMovieRequest struct {
ExternalID string
Title string `json:"title"`
Rated string `json:"rated"`
Released string `json:"release_date"`
RunTime int64 `json:"run_time"`
Director string `json:"director"`
Writer string `json:"writer"`
}
// MovieResponse is the response struct for a Movie
type MovieResponse struct {
ExternalID string `json:"external_id"`
Title string `json:"title"`
Rated string `json:"rated"`
Released string `json:"release_date"`
RunTime int64 `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"`
}
================================================
FILE: movie_test.go
================================================
package diygoapi
import (
"testing"
"time"
qt "github.com/frankban/quicktest"
"github.com/google/go-cmp/cmp"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
func TestMovie_IsValid(t *testing.T) {
c := qt.New(t)
rd, _ := time.Parse(time.RFC3339, "1985-08-16T00:00:00Z")
movieFunc := func() *Movie {
return &Movie{
ID: uuid.New(),
ExternalID: secure.NewID(),
Title: "The Return of the Living Dead",
Rated: "R",
Released: rd,
RunTime: 91,
Director: "Dan O'Bannon",
Writer: "Russell Streiner",
}
}
m1 := movieFunc()
m2 := movieFunc()
m2.ExternalID = nil
m2a := movieFunc()
m2a.ExternalID = secure.Identifier{}
m3 := movieFunc()
m3.Title = ""
m4 := movieFunc()
m4.Rated = ""
m5 := movieFunc()
m5.Released = time.Time{}
m6 := movieFunc()
m6.RunTime = 0
m7 := movieFunc()
m7.Director = ""
m8 := movieFunc()
m8.Writer = ""
tests := []struct {
name string
m *Movie
wantErr error
}{
{"typical no error", m1, nil},
{"nil ExternalID", m2, errs.E(errs.Validation, errs.Parameter("extlID"), errs.MissingField("extlID"))},
{"empty ExternalID", m2a, errs.E(errs.Validation, errs.Parameter("extlID"), errs.MissingField("extlID"))},
{"empty Title", m3, errs.E(errs.Validation, errs.Parameter("title"), errs.MissingField("title"))},
{"empty Rated", m4, errs.E(errs.Validation, errs.Parameter("rated"), errs.MissingField("rated"))},
{"zero Released", m5, errs.E(errs.Validation, errs.Parameter("release_date"), "release_date must have a value")},
{"zero RunTime", m6, errs.E(errs.Validation, errs.Parameter("run_time"), "run_time must be greater than zero")},
{"empty Director", m7, errs.E(errs.Validation, errs.Parameter("director"), errs.MissingField("director"))},
{"empty Writer", m8, errs.E(errs.Validation, errs.Parameter("writer"), errs.MissingField("writer"))},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValidErr := tt.m.IsValid()
if (isValidErr != nil) && (tt.wantErr == nil) {
t.Errorf("IsValid() error = %v; nil expected", isValidErr)
return
}
c.Assert(isValidErr, qt.CmpEquals(cmp.Comparer(errs.Match)), tt.wantErr)
})
}
}
================================================
FILE: org.go
================================================
package diygoapi
import (
"context"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
// OrgServicer manages the retrieval and manipulation of an Org
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)
}
// OrgKind is a way of classifying an organization. Examples are Genesis, Test, Standard
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
}
// Validate determines whether the Person has proper data to be considered valid
func (o OrgKind) Validate() error {
const op errs.Op = "diygoapi/OrgKind.Validate"
switch {
case o.ID == uuid.Nil:
return errs.E(op, errs.Validation, "OrgKind ID cannot be nil")
case o.ExternalID == "":
return errs.E(op, errs.Validation, "OrgKind ExternalID cannot be empty")
case o.Description == "":
return errs.E(op, errs.Validation, "OrgKind Description cannot be empty")
}
return nil
}
// Org represents an Organization (company, institution or any other
// organized body of people with a particular purpose)
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
}
// Validate determines whether the Org has proper data to be considered valid
func (o Org) Validate() (err error) {
const op errs.Op = "diygoapi/Org.Validate"
switch {
case o.ID == uuid.Nil:
return errs.E(op, errs.Validation, "Org ID cannot be nil")
case o.ExternalID.String() == "":
return errs.E(op, errs.Validation, "Org ExternalID cannot be empty")
case o.Name == "":
return errs.E(op, errs.Validation, "Org Name cannot be empty")
case o.Description == "":
return errs.E(op, errs.Validation, "Org Description cannot be empty")
}
if err = o.Kind.Validate(); err != nil {
return errs.E(op, err)
}
return nil
}
// CreateOrgRequest is the request struct for Creating an Org
type CreateOrgRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Kind string `json:"kind"`
CreateAppRequest *CreateAppRequest `json:"app"`
}
// Validate determines whether the CreateOrgRequest has proper data to be considered valid
func (r CreateOrgRequest) Validate() error {
const op errs.Op = "diygoapi/CreateOrgRequest.Validate"
switch {
case r.Name == "":
return errs.E(op, errs.Validation, "org name is required")
case r.Description == "":
return errs.E(op, errs.Validation, "org description is required")
case r.Kind == "":
return errs.E(op, errs.Validation, "org kind is required")
}
return nil
}
// UpdateOrgRequest is the request struct for Updating an Org
type UpdateOrgRequest struct {
ExternalID string
Name string `json:"name"`
Description string `json:"description"`
}
// 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 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"`
}
================================================
FILE: user.go
================================================
package diygoapi
import (
"context"
"time"
"golang.org/x/text/language"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
// RegisterUserServicer registers a new user
type RegisterUserServicer interface {
SelfRegister(ctx context.Context, adt Audit) error
}
// 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.
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
}
// Validate determines whether the Person has proper data to be considered valid
func (p Person) Validate() (err error) {
const op errs.Op = "diygoapi/Person.Validate"
switch {
case p.ID == uuid.Nil:
return errs.E(op, errs.Validation, "Person ID cannot be nil")
case p.ExternalID.String() == "":
return errs.E(op, errs.Validation, "Person ExternalID cannot be empty")
}
return nil
}
// 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.
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"`
}
// 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.
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
}
// Validate determines whether the Person has proper data to be considered valid
func (u User) Validate() error {
const op errs.Op = "diygoapi/User.Validate"
switch {
case u.ID == uuid.Nil:
return errs.E(op, errs.Validation, "User ID cannot be nil")
case u.ExternalID.String() == "":
return errs.E(op, errs.Validation, "User ExternalID cannot be empty")
case u.LastName == "":
return errs.E(op, errs.Validation, "User LastName cannot be empty")
case u.FirstName == "":
return errs.E(op, errs.Validation, "User FirstName cannot be empty")
}
return nil
}
// NewUserFromProviderInfo creates a new User struct to be used in db user creation
func NewUserFromProviderInfo(pi *ProviderInfo, lm language.Matcher) *User {
var langPrefs []language.Tag
// Match the user's locale to the supported languages
langPref, _, _ := lm.Match(language.Make(pi.UserInfo.Locale))
// Append the matched language to the language preferences
langPrefs = append(langPrefs, langPref)
// create User from ProviderInfo
u := &User{
ID: uuid.New(),
ExternalID: secure.NewID(),
NamePrefix: pi.UserInfo.NamePrefix,
FirstName: pi.UserInfo.FirstName,
MiddleName: pi.UserInfo.MiddleName,
LastName: pi.UserInfo.LastName,
FullName: pi.UserInfo.FullName,
NameSuffix: pi.UserInfo.NameSuffix,
Nickname: pi.UserInfo.Nickname,
Gender: pi.UserInfo.Gender,
Email: pi.UserInfo.Email,
BirthDate: pi.UserInfo.BirthDate,
LanguagePreferences: langPrefs,
HostedDomain: pi.UserInfo.HostedDomain,
PictureURL: pi.UserInfo.Picture,
ProfileLink: pi.UserInfo.ProfileLink,
Source: pi.Provider.String(),
}
return u
}
================================================
FILE: user_test.go
================================================
package diygoapi
import (
"testing"
"time"
qt "github.com/frankban/quicktest"
"github.com/google/go-cmp/cmp"
"github.com/gilcrest/diygoapi/errs"
"github.com/gilcrest/diygoapi/secure"
"github.com/gilcrest/diygoapi/uuid"
)
func TestUser_Validate(t *testing.T) {
c := qt.New(t)
type fields struct {
Email string
LastName string
FirstName string
FullName string
HostedDomain string
PictureURL string
ProfileLink string
}
otto := fields{
Email: "otto.maddox@helpinghandacceptanceco.com",
LastName: "Maddox",
FirstName: "Otto",
FullName: "Otto Maddox",
HostedDomain: "",
PictureURL: "",
ProfileLink: "",
}
noLastName := fields{
Email: "otto.maddox@helpinghandacceptanceco.com",
LastName: "",
FirstName: "Otto",
FullName: "Otto Maddox",
HostedDomain: "",
PictureURL: "",
ProfileLink: "",
}
noFirstName := fields{
Email: "otto.maddox@helpinghandacceptanceco.com",
LastName: "Maddox",
FirstName: "",
FullName: "Otto Maddox",
HostedDomain: "",
PictureURL: "",
ProfileLink: "",
}
tests := []struct {
name string
fields fields
wantErr error
}{
{"typical", otto, nil},
{"no last name", noLastName, errs.E(errs.Validation, "User LastName cannot be empty")},
{"no first name", noFirstName, errs.E(errs.Validation, "User FirstName cannot be empty")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := User{
ID: uuid.New(),
ExternalID: secure.NewID(),
NamePrefix: "",
FirstName: tt.fields.FirstName,
MiddleName: "",
LastName: tt.fields.LastName,
FullName: tt.fields.FullName,
NameSuffix: "",
Nickname: "",
Email: tt.fields.Email,
CompanyName: "",
CompanyDepartment: "",
JobTitle: "",
BirthDate: time.Date(2008, 1, 17, 0, 0, 0, 0, time.UTC),
LanguagePreferences: nil,
HostedDomain: tt.fields.HostedDomain,
PictureURL: tt.fields.PictureURL,
ProfileLink: tt.fields.ProfileLink,
Source: "",
}
err := user.Validate()
c.Assert(err, qt.CmpEquals(cmp.Comparer(errs.Match)), tt.wantErr)
})
}
}
gitextract_odti4dkr/ ├── .claude/ │ └── skills/ │ └── db-init-config/ │ └── SKILL.md ├── .git/ │ ├── HEAD │ ├── config │ ├── description │ ├── hooks/ │ │ ├── applypatch-msg.sample │ │ ├── commit-msg.sample │ │ ├── fsmonitor-watchman.sample │ │ ├── post-update.sample │ │ ├── pre-applypatch.sample │ │ ├── pre-commit.sample │ │ ├── pre-merge-commit.sample │ │ ├── pre-push.sample │ │ ├── pre-rebase.sample │ │ ├── pre-receive.sample │ │ ├── prepare-commit-msg.sample │ │ ├── push-to-checkout.sample │ │ ├── sendemail-validate.sample │ │ └── update.sample │ ├── index │ ├── info/ │ │ └── exclude │ ├── logs/ │ │ ├── HEAD │ │ └── refs/ │ │ ├── heads/ │ │ │ └── main │ │ └── remotes/ │ │ └── origin/ │ │ └── HEAD │ ├── objects/ │ │ └── pack/ │ │ ├── pack-4cf8052845b33e6cfeabee017e720515ff38889e.idx │ │ ├── pack-4cf8052845b33e6cfeabee017e720515ff38889e.pack │ │ ├── pack-4cf8052845b33e6cfeabee017e720515ff38889e.promisor │ │ └── pack-4cf8052845b33e6cfeabee017e720515ff38889e.rev │ ├── packed-refs │ ├── refs/ │ │ ├── heads/ │ │ │ └── main │ │ └── remotes/ │ │ └── origin/ │ │ └── HEAD │ └── shallow ├── .github/ │ └── dependabot.yml ├── .gitignore ├── CLAUDE.md ├── README.md ├── Taskfile.yml ├── app.go ├── app_test.go ├── auth.go ├── auth_test.go ├── context.go ├── context_test.go ├── db.go ├── diygoapi.go ├── go.mod ├── go.sum ├── movie.go ├── movie_test.go ├── org.go ├── user.go └── user_test.go
SYMBOL INDEX (113 symbols across 13 files)
FILE: app.go
type AppServicer (line 15) | type AppServicer interface
type APIKeyGenerator (line 21) | type APIKeyGenerator interface
type App (line 26) | type App struct
method AddKey (line 38) | func (a *App) AddKey(key APIKey) error {
method ValidateKey (line 52) | func (a *App) ValidateKey(realm, matchKey string) error {
method matchKey (line 68) | func (a *App) matchKey(realm, matchKey string) (APIKey, error) {
type CreateAppRequest (line 80) | type CreateAppRequest struct
method Validate (line 88) | func (r CreateAppRequest) Validate() error {
type UpdateAppRequest (line 116) | type UpdateAppRequest struct
type AppResponse (line 123) | type AppResponse struct
type APIKeyResponse (line 139) | type APIKeyResponse struct
type APIKey (line 147) | type APIKey struct
method Key (line 208) | func (a *APIKey) Key() string {
method Ciphertext (line 213) | func (a *APIKey) Ciphertext() string {
method DeactivationDate (line 218) | func (a *APIKey) DeactivationDate() time.Time {
method SetDeactivationDate (line 224) | func (a *APIKey) SetDeactivationDate(t time.Time) {
method SetStringAsDeactivationDate (line 230) | func (a *APIKey) SetStringAsDeactivationDate(s string) error {
method validate (line 242) | func (a *APIKey) validate() error {
function NewAPIKey (line 160) | func NewAPIKey(g APIKeyGenerator, ek *[32]byte, deactivation time.Time) ...
function NewAPIKeyFromCipher (line 184) | func NewAPIKeyFromCipher(ciphertext string, ek *[32]byte) (APIKey, error) {
FILE: app_test.go
function TestApp_AddKey (line 17) | func TestApp_AddKey(t *testing.T) {
function TestApp_ValidateKey (line 87) | func TestApp_ValidateKey(t *testing.T) {
function TestNewAPIKey (line 184) | func TestNewAPIKey(t *testing.T) {
FILE: auth.go
constant AppIDHeaderKey (line 19) | AppIDHeaderKey string = "X-APP-ID"
constant ApiKeyHeaderKey (line 21) | ApiKeyHeaderKey string = "X-API-KEY"
constant AuthProviderHeaderKey (line 23) | AuthProviderHeaderKey string = "X-AUTH-PROVIDER"
type PermissionServicer (line 27) | type PermissionServicer interface
type RoleServicer (line 35) | type RoleServicer interface
type AuthenticationServicer (line 51) | type AuthenticationServicer interface
type AuthorizationServicer (line 87) | type AuthorizationServicer interface
type TokenExchanger (line 93) | type TokenExchanger interface
constant BearerTokenType (line 98) | BearerTokenType string = "Bearer"
type Provider (line 103) | type Provider
method String (line 113) | func (p Provider) String() string {
constant UnknownProvider (line 109) | UnknownProvider Provider = iota
constant Google (line 110) | Google
function ParseProvider (line 123) | func ParseProvider(s string) Provider {
type ProviderInfo (line 132) | type ProviderInfo struct
type ProviderTokenInfo (line 141) | type ProviderTokenInfo struct
type ProviderUserInfo (line 171) | type ProviderUserInfo struct
type Auth (line 227) | type Auth struct
type Permission (line 258) | type Permission struct
method Validate (line 274) | func (p Permission) Validate() error {
type CreatePermissionRequest (line 291) | type CreatePermissionRequest struct
type FindPermissionRequest (line 303) | type FindPermissionRequest struct
type PermissionResponse (line 313) | type PermissionResponse struct
type Role (line 327) | type Role struct
method Validate (line 343) | func (r Role) Validate() error {
type CreateRoleRequest (line 361) | type CreateRoleRequest struct
type RoleResponse (line 373) | type RoleResponse struct
type AuthenticationParams (line 387) | type AuthenticationParams struct
FILE: auth_test.go
function TestNewProvider (line 10) | func TestNewProvider(t *testing.T) {
function TestProvider_String (line 23) | func TestProvider_String(t *testing.T) {
FILE: context.go
type contextKey (line 11) | type contextKey
constant handlerPatternKey (line 14) | handlerPatternKey contextKey = "handlerPattern"
constant appContextKey (line 15) | appContextKey contextKey = "app"
constant contextKeyUser (line 16) | contextKeyUser contextKey = "user"
constant authParamsContextKey (line 17) | authParamsContextKey contextKey = "authParams"
function NewContextWithRequestHandlerPattern (line 21) | func NewContextWithRequestHandlerPattern(ctx context.Context, pattern st...
function HandlerPatternFromRequest (line 27) | func HandlerPatternFromRequest(r *http.Request) (string, error) {
function RequestHandlerPatternFromContext (line 39) | func RequestHandlerPatternFromContext(ctx context.Context) (string, erro...
function NewContextWithApp (line 55) | func NewContextWithApp(ctx context.Context, a *App) context.Context {
function AppFromRequest (line 61) | func AppFromRequest(r *http.Request) (*App, error) {
function AppFromContext (line 73) | func AppFromContext(ctx context.Context) (*App, error) {
function NewContextWithUser (line 84) | func NewContextWithUser(ctx context.Context, u *User) context.Context {
function UserFromRequest (line 89) | func UserFromRequest(r *http.Request) (u *User, err error) {
function UserFromContext (line 101) | func UserFromContext(ctx context.Context) (*User, error) {
function AuditFromRequest (line 114) | func AuditFromRequest(r *http.Request) (adt Audit, err error) {
function NewContextWithAuthParams (line 137) | func NewContextWithAuthParams(ctx context.Context, ap *AuthenticationPar...
function AuthParamsFromContext (line 142) | func AuthParamsFromContext(ctx context.Context) (*AuthenticationParams, ...
FILE: context_test.go
function TestUserFromRequest (line 18) | func TestUserFromRequest(t *testing.T) {
FILE: db.go
type Datastorer (line 14) | type Datastorer interface
type DBTX (line 27) | type DBTX interface
type PingServicer (line 34) | type PingServicer interface
type PingResponse (line 39) | type PingResponse struct
function NewPgxInt4 (line 44) | func NewPgxInt4(i int32) pgtype.Int4 {
function NewPgxInt8 (line 52) | func NewPgxInt8(i int64) pgtype.Int8 {
function NewPgxText (line 61) | func NewPgxText(s string) pgtype.Text {
function NewPgxTimestampTZ (line 72) | func NewPgxTimestampTZ(t time.Time) pgtype.Timestamptz {
function NewPgxDate (line 80) | func NewPgxDate(t time.Time) pgtype.Date {
FILE: diygoapi.go
type LoggerServicer (line 10) | type LoggerServicer interface
type GenesisServicer (line 16) | type GenesisServicer interface
type Audit (line 25) | type Audit struct
type SimpleAudit (line 34) | type SimpleAudit struct
type DeleteResponse (line 41) | type DeleteResponse struct
type LoggerRequest (line 47) | type LoggerRequest struct
type LoggerResponse (line 54) | type LoggerResponse struct
type GenesisRequest (line 61) | type GenesisRequest struct
type GenesisResponse (line 80) | type GenesisResponse struct
FILE: movie.go
type MovieServicer (line 13) | type MovieServicer interface
type Movie (line 22) | type Movie struct
method IsValid (line 34) | func (m *Movie) IsValid() error {
type CreateMovieRequest (line 58) | type CreateMovieRequest struct
type UpdateMovieRequest (line 68) | type UpdateMovieRequest struct
type MovieResponse (line 79) | type MovieResponse struct
FILE: movie_test.go
function TestMovie_IsValid (line 15) | func TestMovie_IsValid(t *testing.T) {
FILE: org.go
type OrgServicer (line 12) | type OrgServicer interface
type OrgKind (line 22) | type OrgKind struct
method Validate (line 32) | func (o OrgKind) Validate() error {
type Org (line 49) | type Org struct
method Validate (line 63) | func (o Org) Validate() (err error) {
type CreateOrgRequest (line 85) | type CreateOrgRequest struct
method Validate (line 93) | func (r CreateOrgRequest) Validate() error {
type UpdateOrgRequest (line 108) | type UpdateOrgRequest struct
type OrgResponse (line 119) | type OrgResponse struct
FILE: user.go
type RegisterUserServicer (line 15) | type RegisterUserServicer interface
type Person (line 28) | type Person struct
method Validate (line 41) | func (p Person) Validate() (err error) {
type UserResponse (line 62) | type UserResponse struct
type User (line 130) | type User struct
method Validate (line 194) | func (u User) Validate() error {
function NewUserFromProviderInfo (line 212) | func NewUserFromProviderInfo(pi *ProviderInfo, lm language.Matcher) *User {
FILE: user_test.go
function TestUser_Validate (line 15) | func TestUser_Validate(t *testing.T) {
Condensed preview — 51 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (191K chars).
[
{
"path": ".claude/skills/db-init-config/SKILL.md",
"chars": 5061,
"preview": " # db-init-config\n\nInteractive setup for `config/config.cue` — the local configuration file needed before running `ta"
},
{
"path": ".git/HEAD",
"chars": 21,
"preview": "ref: refs/heads/main\n"
},
{
"path": ".git/config",
"chars": 341,
"preview": "[core]\n\trepositoryformatversion = 1\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n[remote \"origin\"]\n\turl = htt"
},
{
"path": ".git/description",
"chars": 73,
"preview": "Unnamed repository; edit this file 'description' to name the repository.\n"
},
{
"path": ".git/hooks/applypatch-msg.sample",
"chars": 478,
"preview": "#!/bin/sh\n#\n# An example hook script to check the commit log message taken by\n# applypatch from an e-mail message.\n#\n# T"
},
{
"path": ".git/hooks/commit-msg.sample",
"chars": 896,
"preview": "#!/bin/sh\n#\n# An example hook script to check the commit log message.\n# Called by \"git commit\" with one argument, the na"
},
{
"path": ".git/hooks/fsmonitor-watchman.sample",
"chars": 4726,
"preview": "#!/usr/bin/perl\n\nuse strict;\nuse warnings;\nuse IPC::Open2;\n\n# An example hook script to integrate Watchman\n# (https://fa"
},
{
"path": ".git/hooks/post-update.sample",
"chars": 189,
"preview": "#!/bin/sh\n#\n# An example hook script to prepare a packed repository for use over\n# dumb transports.\n#\n# To enable this h"
},
{
"path": ".git/hooks/pre-applypatch.sample",
"chars": 424,
"preview": "#!/bin/sh\n#\n# An example hook script to verify what is about to be committed\n# by applypatch from an e-mail message.\n#\n#"
},
{
"path": ".git/hooks/pre-commit.sample",
"chars": 1649,
"preview": "#!/bin/sh\n#\n# An example hook script to verify what is about to be committed.\n# Called by \"git commit\" with no arguments"
},
{
"path": ".git/hooks/pre-merge-commit.sample",
"chars": 416,
"preview": "#!/bin/sh\n#\n# An example hook script to verify what is about to be committed.\n# Called by \"git merge\" with no arguments."
},
{
"path": ".git/hooks/pre-push.sample",
"chars": 1374,
"preview": "#!/bin/sh\n\n# An example hook script to verify what is about to be pushed. Called by \"git\n# push\" after it has checked t"
},
{
"path": ".git/hooks/pre-rebase.sample",
"chars": 4898,
"preview": "#!/bin/sh\n#\n# Copyright (c) 2006, 2008 Junio C Hamano\n#\n# The \"pre-rebase\" hook is run just before \"git rebase\" starts d"
},
{
"path": ".git/hooks/pre-receive.sample",
"chars": 544,
"preview": "#!/bin/sh\n#\n# An example hook script to make use of push options.\n# The example simply echoes all push options that star"
},
{
"path": ".git/hooks/prepare-commit-msg.sample",
"chars": 1492,
"preview": "#!/bin/sh\n#\n# An example hook script to prepare the commit log message.\n# Called by \"git commit\" with the name of the fi"
},
{
"path": ".git/hooks/push-to-checkout.sample",
"chars": 2783,
"preview": "#!/bin/sh\n\n# An example hook script to update a checked-out tree on a git push.\n#\n# This hook is invoked by git-receive-"
},
{
"path": ".git/hooks/sendemail-validate.sample",
"chars": 2308,
"preview": "#!/bin/sh\n\n# An example hook script to validate a patch (and/or patch series) before\n# sending it via email.\n#\n# The hoo"
},
{
"path": ".git/hooks/update.sample",
"chars": 3650,
"preview": "#!/bin/sh\n#\n# An example hook script to block unannotated tags from entering.\n# Called by \"git receive-pack\" with argume"
},
{
"path": ".git/info/exclude",
"chars": 240,
"preview": "# git ls-files --others --exclude-from=.git/info/exclude\n# Lines that start with '#' are comments.\n# For a project mostl"
},
{
"path": ".git/logs/HEAD",
"chars": 186,
"preview": "0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> "
},
{
"path": ".git/logs/refs/heads/main",
"chars": 186,
"preview": "0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> "
},
{
"path": ".git/logs/refs/remotes/origin/HEAD",
"chars": 186,
"preview": "0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> "
},
{
"path": ".git/objects/pack/pack-4cf8052845b33e6cfeabee017e720515ff38889e.promisor",
"chars": 57,
"preview": "7a4aacf5c3ee84be2a49944859c55cb586632fc7 refs/heads/main\n"
},
{
"path": ".git/packed-refs",
"chars": 112,
"preview": "# pack-refs with: peeled fully-peeled sorted \n7a4aacf5c3ee84be2a49944859c55cb586632fc7 refs/remotes/origin/main\n"
},
{
"path": ".git/refs/heads/main",
"chars": 41,
"preview": "7a4aacf5c3ee84be2a49944859c55cb586632fc7\n"
},
{
"path": ".git/refs/remotes/origin/HEAD",
"chars": 30,
"preview": "ref: refs/remotes/origin/main\n"
},
{
"path": ".git/shallow",
"chars": 41,
"preview": "7a4aacf5c3ee84be2a49944859c55cb586632fc7\n"
},
{
"path": ".github/dependabot.yml",
"chars": 454,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".gitignore",
"chars": 799,
"preview": "# Ignore all\n*\n\n# Unignore all with extensions\n!*.*\n\n# Unignore all directories\n!*/\n\n# Unignore Dockerfile\n!Dockerfile\n\n"
},
{
"path": "CLAUDE.md",
"chars": 8262,
"preview": "# CLAUDE.md\n\nThis file provides guidance to [Claude Code](https://claude.ai/code) when working with code in this reposit"
},
{
"path": "README.md",
"chars": 60732,
"preview": "# DIY Go API\n\nA RESTful API template (built with Go)\n\nThe goal of this project is to be an example of a relational datab"
},
{
"path": "Taskfile.yml",
"chars": 1614,
"preview": "version: '3'\n\nincludes:\n cue:\n taskfile: ./config/cue/Taskfile.yml\n dir: ./config/cue\n\ntasks:\n db-init:\n desc"
},
{
"path": "app.go",
"chars": 7732,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/"
},
{
"path": "app_test.go",
"chars": 5383,
"preview": "package diygoapi_test\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\tqt \"github.com/frankban/q"
},
{
"path": "auth.go",
"chars": 14250,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog\"\n\t\"golang.org/x/oauth2\"\n\n\t"
},
{
"path": "auth_test.go",
"chars": 848,
"preview": "package diygoapi_test\n\nimport (\n\t\"github.com/gilcrest/diygoapi\"\n\t\"testing\"\n\n\tqt \"github.com/frankban/quicktest\"\n)\n\nfunc "
},
{
"path": "context.go",
"chars": 4200,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n)\n\ntype contextKey strin"
},
{
"path": "context_test.go",
"chars": 1749,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\tqt \"github.com/frankban/quic"
},
{
"path": "db.go",
"chars": 2146,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/jackc/pgconn\"\n\t\"github.com/jackc/pgx/v5\"\n\t\"github.com/jackc/"
},
{
"path": "diygoapi.go",
"chars": 2660,
"preview": "// Package diygoapi comprises application or business domain data types and functions.\npackage diygoapi\n\nimport (\n\t\"cont"
},
{
"path": "go.mod",
"chars": 2185,
"preview": "module github.com/gilcrest/diygoapi\n\ngo 1.26\n\nrequire (\n\tgithub.com/frankban/quicktest v1.14.6\n\tgithub.com/google/go-cmp"
},
{
"path": "go.sum",
"chars": 12151,
"preview": "cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=\ncloud.google.com/go/auth v0.18.2/go.mod"
},
{
"path": "movie.go",
"chars": 3375,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secur"
},
{
"path": "movie_test.go",
"chars": 2259,
"preview": "package diygoapi\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\tqt \"github.com/frankban/quicktest\"\n\t\"github.com/google/go-cmp/cmp\"\n\n\t\"gi"
},
{
"path": "org.go",
"chars": 4657,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"git"
},
{
"path": "user.go",
"chars": 8472,
"preview": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"golang.org/x/text/language\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"git"
},
{
"path": "user_test.go",
"chars": 2347,
"preview": "package diygoapi\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\tqt \"github.com/frankban/quicktest\"\n\t\"github.com/google/go-cmp/cmp\"\n\n\t\"gi"
}
]
// ... and 4 more files (download for full content)
About this extraction
This page contains the full source code of the gilcrest/diygoapi GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 51 files (174.5 KB), approximately 50.5k tokens, and a symbol index with 113 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.