[
  {
    "path": ".claude/skills/db-init-config/SKILL.md",
    "content": "    # db-init-config\n\nInteractive setup for `config/config.cue` — the local configuration file needed before running `task db-init` and `task db-up`.\n\ndisable-model-invocation: true\n\n---\n\n## Instructions\n\nYou 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`.\n\n### Step 1 — Collect admin target values\n\nUse `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:\n\n1. **Admin DB host** — Options: `localhost` (Recommended), `127.0.0.1`. Header: `Admin host`. Question: \"What is the host for your admin PostgreSQL connection?\"\n2. **Admin DB port** — Options: `5432` (Recommended), `5433`. Header: `Admin port`. Question: \"What port is your admin PostgreSQL instance running on?\"\n3. **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.)\n4. **Admin DB user** — Options: OS username (Recommended), `postgres`. Header: `Admin user`. Question: \"What superuser should the admin connection use?\"\n\nAfter receiving answers, use a **second** `AskUserQuestion` call to ask:\n\n5. **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.)\"\n\n### Step 2 — Collect app target values\n\nUse `AskUserQuestion` to collect the **application database** values (what `db-init` will create). Present all six questions in a single call:\n\n1. **App DB host** — Options: `localhost` (Recommended), `127.0.0.1`. Header: `App host`. Question: \"What host should the application database use?\"\n2. **App DB port** — Options: `5432` (Recommended), `5433`. Header: `App port`. Question: \"What port should the application database use?\"\n3. **App DB name** — Options: `dga_local` (Recommended), `diygoapi`. Header: `App DB name`. Question: \"What should the application database be named?\"\n4. **App DB user** — Options: `demo_user` (Recommended), `dga_user`. Header: `App DB user`. Question: \"What database user should be created for the application?\"\n5. **App DB password** — Options: `REPLACE_ME` (Recommended), `Enter password`. Header: `App DB pass`. Question: \"What password should the application database user have?\"\n6. **App DB search path** — Options: `demo` (Recommended), `public`. Header: `Schema`. Question: \"What PostgreSQL schema (search_path) should be used?\"\n\n### Step 3 — Generate encryption key\n\nRun the following command and capture its output (trimmed):\n\n```bash\ngo run ./cmd/newkey/main.go\n```\n\nStore the output as the encryption key value.\n\n### Step 4 — Write config/config.cue\n\nWrite the file `config/config.cue` using the collected values. Use the exact template below, substituting the placeholder tokens with the values collected above.\n\nFor the admin password: if the user chose empty/peer auth, use an empty string `\"\"`. Otherwise use the password they provided.\n\n```cue\npackage config\n\n_localAdminTarget: #Target & {\n\ttarget:               \"local-admin\"\n\tserver_listener_port: 8080\n\tlogger: {\n\t\tmin_log_level:   \"trace\"\n\t\tlog_level:       \"debug\"\n\t\tlog_error_stack: true\n\t}\n\tencryption_key: \"{{ENCRYPTION_KEY}}\"\n\tdatabase: {\n\t\thost:        \"{{ADMIN_DB_HOST}}\"\n\t\tport:        {{ADMIN_DB_PORT}}\n\t\tname:        \"{{ADMIN_DB_NAME}}\"\n\t\tuser:        \"{{ADMIN_DB_USER}}\"\n\t\tpassword:    \"{{ADMIN_DB_PASSWORD}}\"\n\t\tsearch_path: \"public\"\n\t}\n}\n\n_localTarget: #Target & {\n\ttarget:               \"local\"\n\tserver_listener_port: 8080\n\tlogger: {\n\t\tmin_log_level:   \"trace\"\n\t\tlog_level:       \"debug\"\n\t\tlog_error_stack: true\n\t}\n\tencryption_key: \"{{ENCRYPTION_KEY}}\"\n\tdatabase: {\n\t\thost:        \"{{APP_DB_HOST}}\"\n\t\tport:        {{APP_DB_PORT}}\n\t\tname:        \"{{APP_DB_NAME}}\"\n\t\tuser:        \"{{APP_DB_USER}}\"\n\t\tpassword:    \"{{APP_DB_PASSWORD}}\"\n\t\tsearch_path: \"{{APP_DB_SEARCH_PATH}}\"\n\t}\n}\n\n#Config & {\n\tdefault_target: \"local\"\n\ttargets: [_localAdminTarget, _localTarget]\n}\n```\n\n**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).\n\n### Step 5 — Run gen-config\n\nRun `task gen-config` to validate and export the config:\n\n```bash\ntask gen-config\n```\n\nIf this succeeds, inform the user that their config is ready, and they can now run:\n\n```\ntask db-init -- --db-admin-config-target local-admin\ntask db-up\n```\n\nIf `task gen-config` fails, show the error to the user and help them fix the `config/config.cue` file.\n"
  },
  {
    "path": ".git/HEAD",
    "content": "ref: refs/heads/main\n"
  },
  {
    "path": ".git/config",
    "content": "[core]\n\trepositoryformatversion = 1\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n[remote \"origin\"]\n\turl = https://github.com/gilcrest/diygoapi\n\ttagOpt = --no-tags\n\tfetch = +refs/heads/main:refs/remotes/origin/main\n\tpromisor = true\n\tpartialclonefilter = blob:limit=1048576\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n"
  },
  {
    "path": ".git/description",
    "content": "Unnamed repository; edit this file 'description' to name the repository.\n"
  },
  {
    "path": ".git/hooks/applypatch-msg.sample",
    "content": "#!/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# The hook should exit with non-zero status after issuing an\n# appropriate message if it wants to stop the commit.  The hook is\n# allowed to edit the commit message file.\n#\n# To enable this hook, rename this file to \"applypatch-msg\".\n\n. git-sh-setup\ncommitmsg=\"$(git rev-parse --git-path hooks/commit-msg)\"\ntest -x \"$commitmsg\" && exec \"$commitmsg\" ${1+\"$@\"}\n:\n"
  },
  {
    "path": ".git/hooks/commit-msg.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to check the commit log message.\n# Called by \"git commit\" with one argument, the name of the file\n# that has the commit message.  The hook should exit with non-zero\n# status after issuing an appropriate message if it wants to stop the\n# commit.  The hook is allowed to edit the commit message file.\n#\n# To enable this hook, rename this file to \"commit-msg\".\n\n# Uncomment the below to add a Signed-off-by line to the message.\n# Doing this in a hook is a bad idea in general, but the prepare-commit-msg\n# hook is more suited to it.\n#\n# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\\(.*>\\).*$/Signed-off-by: \\1/p')\n# grep -qs \"^$SOB\" \"$1\" || echo \"$SOB\" >> \"$1\"\n\n# This example catches duplicate Signed-off-by lines.\n\ntest \"\" = \"$(grep '^Signed-off-by: ' \"$1\" |\n\t sort | uniq -c | sed -e '/^[ \t]*1[ \t]/d')\" || {\n\techo >&2 Duplicate Signed-off-by lines.\n\texit 1\n}\n"
  },
  {
    "path": ".git/hooks/fsmonitor-watchman.sample",
    "content": "#!/usr/bin/perl\n\nuse strict;\nuse warnings;\nuse IPC::Open2;\n\n# An example hook script to integrate Watchman\n# (https://facebook.github.io/watchman/) with git to speed up detecting\n# new and modified files.\n#\n# The hook is passed a version (currently 2) and last update token\n# formatted as a string and outputs to stdout a new update token and\n# all files that have been modified since the update token. Paths must\n# be relative to the root of the working tree and separated by a single NUL.\n#\n# To enable this hook, rename this file to \"query-watchman\" and set\n# 'git config core.fsmonitor .git/hooks/query-watchman'\n#\nmy ($version, $last_update_token) = @ARGV;\n\n# Uncomment for debugging\n# print STDERR \"$0 $version $last_update_token\\n\";\n\n# Check the hook interface version\nif ($version ne 2) {\n\tdie \"Unsupported query-fsmonitor hook version '$version'.\\n\" .\n\t    \"Falling back to scanning...\\n\";\n}\n\nmy $git_work_tree = get_working_dir();\n\nmy $retry = 1;\n\nmy $json_pkg;\neval {\n\trequire JSON::XS;\n\t$json_pkg = \"JSON::XS\";\n\t1;\n} or do {\n\trequire JSON::PP;\n\t$json_pkg = \"JSON::PP\";\n};\n\nlaunch_watchman();\n\nsub launch_watchman {\n\tmy $o = watchman_query();\n\tif (is_work_tree_watched($o)) {\n\t\toutput_result($o->{clock}, @{$o->{files}});\n\t}\n}\n\nsub output_result {\n\tmy ($clockid, @files) = @_;\n\n\t# Uncomment for debugging watchman output\n\t# open (my $fh, \">\", \".git/watchman-output.out\");\n\t# binmode $fh, \":utf8\";\n\t# print $fh \"$clockid\\n@files\\n\";\n\t# close $fh;\n\n\tbinmode STDOUT, \":utf8\";\n\tprint $clockid;\n\tprint \"\\0\";\n\tlocal $, = \"\\0\";\n\tprint @files;\n}\n\nsub watchman_clock {\n\tmy $response = qx/watchman clock \"$git_work_tree\"/;\n\tdie \"Failed to get clock id on '$git_work_tree'.\\n\" .\n\t\t\"Falling back to scanning...\\n\" if $? != 0;\n\n\treturn $json_pkg->new->utf8->decode($response);\n}\n\nsub watchman_query {\n\tmy $pid = open2(\\*CHLD_OUT, \\*CHLD_IN, 'watchman -j --no-pretty')\n\tor die \"open2() failed: $!\\n\" .\n\t\"Falling back to scanning...\\n\";\n\n\t# In the query expression below we're asking for names of files that\n\t# changed since $last_update_token but not from the .git folder.\n\t#\n\t# To accomplish this, we're using the \"since\" generator to use the\n\t# recency index to select candidate nodes and \"fields\" to limit the\n\t# output to file names only. Then we're using the \"expression\" term to\n\t# further constrain the results.\n\tmy $last_update_line = \"\";\n\tif (substr($last_update_token, 0, 1) eq \"c\") {\n\t\t$last_update_token = \"\\\"$last_update_token\\\"\";\n\t\t$last_update_line = qq[\\n\"since\": $last_update_token,];\n\t}\n\tmy $query = <<\"\tEND\";\n\t\t[\"query\", \"$git_work_tree\", {$last_update_line\n\t\t\t\"fields\": [\"name\"],\n\t\t\t\"expression\": [\"not\", [\"dirname\", \".git\"]]\n\t\t}]\n\tEND\n\n\t# Uncomment for debugging the watchman query\n\t# open (my $fh, \">\", \".git/watchman-query.json\");\n\t# print $fh $query;\n\t# close $fh;\n\n\tprint CHLD_IN $query;\n\tclose CHLD_IN;\n\tmy $response = do {local $/; <CHLD_OUT>};\n\n\t# Uncomment for debugging the watch response\n\t# open ($fh, \">\", \".git/watchman-response.json\");\n\t# print $fh $response;\n\t# close $fh;\n\n\tdie \"Watchman: command returned no output.\\n\" .\n\t\"Falling back to scanning...\\n\" if $response eq \"\";\n\tdie \"Watchman: command returned invalid output: $response\\n\" .\n\t\"Falling back to scanning...\\n\" unless $response =~ /^\\{/;\n\n\treturn $json_pkg->new->utf8->decode($response);\n}\n\nsub is_work_tree_watched {\n\tmy ($output) = @_;\n\tmy $error = $output->{error};\n\tif ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {\n\t\t$retry--;\n\t\tmy $response = qx/watchman watch \"$git_work_tree\"/;\n\t\tdie \"Failed to make watchman watch '$git_work_tree'.\\n\" .\n\t\t    \"Falling back to scanning...\\n\" if $? != 0;\n\t\t$output = $json_pkg->new->utf8->decode($response);\n\t\t$error = $output->{error};\n\t\tdie \"Watchman: $error.\\n\" .\n\t\t\"Falling back to scanning...\\n\" if $error;\n\n\t\t# Uncomment for debugging watchman output\n\t\t# open (my $fh, \">\", \".git/watchman-output.out\");\n\t\t# close $fh;\n\n\t\t# Watchman will always return all files on the first query so\n\t\t# return the fast \"everything is dirty\" flag to git and do the\n\t\t# Watchman query just to get it over with now so we won't pay\n\t\t# the cost in git to look up each individual file.\n\t\tmy $o = watchman_clock();\n\t\t$error = $output->{error};\n\n\t\tdie \"Watchman: $error.\\n\" .\n\t\t\"Falling back to scanning...\\n\" if $error;\n\n\t\toutput_result($o->{clock}, (\"/\"));\n\t\t$last_update_token = $o->{clock};\n\n\t\teval { launch_watchman() };\n\t\treturn 0;\n\t}\n\n\tdie \"Watchman: $error.\\n\" .\n\t\"Falling back to scanning...\\n\" if $error;\n\n\treturn 1;\n}\n\nsub get_working_dir {\n\tmy $working_dir;\n\tif ($^O =~ 'msys' || $^O =~ 'cygwin') {\n\t\t$working_dir = Win32::GetCwd();\n\t\t$working_dir =~ tr/\\\\/\\//;\n\t} else {\n\t\trequire Cwd;\n\t\t$working_dir = Cwd::cwd();\n\t}\n\n\treturn $working_dir;\n}\n"
  },
  {
    "path": ".git/hooks/post-update.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to prepare a packed repository for use over\n# dumb transports.\n#\n# To enable this hook, rename this file to \"post-update\".\n\nexec git update-server-info\n"
  },
  {
    "path": ".git/hooks/pre-applypatch.sample",
    "content": "#!/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# The hook should exit with non-zero status after issuing an\n# appropriate message if it wants to stop the commit.\n#\n# To enable this hook, rename this file to \"pre-applypatch\".\n\n. git-sh-setup\nprecommit=\"$(git rev-parse --git-path hooks/pre-commit)\"\ntest -x \"$precommit\" && exec \"$precommit\" ${1+\"$@\"}\n:\n"
  },
  {
    "path": ".git/hooks/pre-commit.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to verify what is about to be committed.\n# Called by \"git commit\" with no arguments.  The hook should\n# exit with non-zero status after issuing an appropriate message if\n# it wants to stop the commit.\n#\n# To enable this hook, rename this file to \"pre-commit\".\n\nif git rev-parse --verify HEAD >/dev/null 2>&1\nthen\n\tagainst=HEAD\nelse\n\t# Initial commit: diff against an empty tree object\n\tagainst=$(git hash-object -t tree /dev/null)\nfi\n\n# If you want to allow non-ASCII filenames set this variable to true.\nallownonascii=$(git config --type=bool hooks.allownonascii)\n\n# Redirect output to stderr.\nexec 1>&2\n\n# Cross platform projects tend to avoid non-ASCII filenames; prevent\n# them from being added to the repository. We exploit the fact that the\n# printable range starts at the space character and ends with tilde.\nif [ \"$allownonascii\" != \"true\" ] &&\n\t# Note that the use of brackets around a tr range is ok here, (it's\n\t# even required, for portability to Solaris 10's /usr/bin/tr), since\n\t# the square bracket bytes happen to fall in the designated range.\n\ttest $(git diff-index --cached --name-only --diff-filter=A -z $against |\n\t  LC_ALL=C tr -d '[ -~]\\0' | wc -c) != 0\nthen\n\tcat <<\\EOF\nError: Attempt to add a non-ASCII file name.\n\nThis can cause problems if you want to work with people on other platforms.\n\nTo be portable it is advisable to rename the file.\n\nIf you know what you are doing you can disable this check using:\n\n  git config hooks.allownonascii true\nEOF\n\texit 1\nfi\n\n# If there are whitespace errors, print the offending file names and fail.\nexec git diff-index --check --cached $against --\n"
  },
  {
    "path": ".git/hooks/pre-merge-commit.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to verify what is about to be committed.\n# Called by \"git merge\" with no arguments.  The hook should\n# exit with non-zero status after issuing an appropriate message to\n# stderr if it wants to stop the merge commit.\n#\n# To enable this hook, rename this file to \"pre-merge-commit\".\n\n. git-sh-setup\ntest -x \"$GIT_DIR/hooks/pre-commit\" &&\n        exec \"$GIT_DIR/hooks/pre-commit\"\n:\n"
  },
  {
    "path": ".git/hooks/pre-push.sample",
    "content": "#!/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 the remote status, but before anything has been\n# pushed.  If this script exits with a non-zero status nothing will be pushed.\n#\n# This hook is called with the following parameters:\n#\n# $1 -- Name of the remote to which the push is being done\n# $2 -- URL to which the push is being done\n#\n# If pushing without using a named remote those arguments will be equal.\n#\n# Information about the commits which are being pushed is supplied as lines to\n# the standard input in the form:\n#\n#   <local ref> <local oid> <remote ref> <remote oid>\n#\n# This sample shows how to prevent push of commits where the log message starts\n# with \"WIP\" (work in progress).\n\nremote=\"$1\"\nurl=\"$2\"\n\nzero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')\n\nwhile read local_ref local_oid remote_ref remote_oid\ndo\n\tif test \"$local_oid\" = \"$zero\"\n\tthen\n\t\t# Handle delete\n\t\t:\n\telse\n\t\tif test \"$remote_oid\" = \"$zero\"\n\t\tthen\n\t\t\t# New branch, examine all commits\n\t\t\trange=\"$local_oid\"\n\t\telse\n\t\t\t# Update to existing branch, examine new commits\n\t\t\trange=\"$remote_oid..$local_oid\"\n\t\tfi\n\n\t\t# Check for WIP commit\n\t\tcommit=$(git rev-list -n 1 --grep '^WIP' \"$range\")\n\t\tif test -n \"$commit\"\n\t\tthen\n\t\t\techo >&2 \"Found WIP commit in $local_ref, not pushing\"\n\t\t\texit 1\n\t\tfi\n\tfi\ndone\n\nexit 0\n"
  },
  {
    "path": ".git/hooks/pre-rebase.sample",
    "content": "#!/bin/sh\n#\n# Copyright (c) 2006, 2008 Junio C Hamano\n#\n# The \"pre-rebase\" hook is run just before \"git rebase\" starts doing\n# its job, and can prevent the command from running by exiting with\n# non-zero status.\n#\n# The hook is called with the following parameters:\n#\n# $1 -- the upstream the series was forked from.\n# $2 -- the branch being rebased (or empty when rebasing the current branch).\n#\n# This sample shows how to prevent topic branches that are already\n# merged to 'next' branch from getting rebased, because allowing it\n# would result in rebasing already published history.\n\npublish=next\nbasebranch=\"$1\"\nif test \"$#\" = 2\nthen\n\ttopic=\"refs/heads/$2\"\nelse\n\ttopic=`git symbolic-ref HEAD` ||\n\texit 0 ;# we do not interrupt rebasing detached HEAD\nfi\n\ncase \"$topic\" in\nrefs/heads/??/*)\n\t;;\n*)\n\texit 0 ;# we do not interrupt others.\n\t;;\nesac\n\n# Now we are dealing with a topic branch being rebased\n# on top of master.  Is it OK to rebase it?\n\n# Does the topic really exist?\ngit show-ref -q \"$topic\" || {\n\techo >&2 \"No such branch $topic\"\n\texit 1\n}\n\n# Is topic fully merged to master?\nnot_in_master=`git rev-list --pretty=oneline ^master \"$topic\"`\nif test -z \"$not_in_master\"\nthen\n\techo >&2 \"$topic is fully merged to master; better remove it.\"\n\texit 1 ;# we could allow it, but there is no point.\nfi\n\n# Is topic ever merged to next?  If so you should not be rebasing it.\nonly_next_1=`git rev-list ^master \"^$topic\" ${publish} | sort`\nonly_next_2=`git rev-list ^master           ${publish} | sort`\nif test \"$only_next_1\" = \"$only_next_2\"\nthen\n\tnot_in_topic=`git rev-list \"^$topic\" master`\n\tif test -z \"$not_in_topic\"\n\tthen\n\t\techo >&2 \"$topic is already up to date with master\"\n\t\texit 1 ;# we could allow it, but there is no point.\n\telse\n\t\texit 0\n\tfi\nelse\n\tnot_in_next=`git rev-list --pretty=oneline ^${publish} \"$topic\"`\n\t/usr/bin/perl -e '\n\t\tmy $topic = $ARGV[0];\n\t\tmy $msg = \"* $topic has commits already merged to public branch:\\n\";\n\t\tmy (%not_in_next) = map {\n\t\t\t/^([0-9a-f]+) /;\n\t\t\t($1 => 1);\n\t\t} split(/\\n/, $ARGV[1]);\n\t\tfor my $elem (map {\n\t\t\t\t/^([0-9a-f]+) (.*)$/;\n\t\t\t\t[$1 => $2];\n\t\t\t} split(/\\n/, $ARGV[2])) {\n\t\t\tif (!exists $not_in_next{$elem->[0]}) {\n\t\t\t\tif ($msg) {\n\t\t\t\t\tprint STDERR $msg;\n\t\t\t\t\tundef $msg;\n\t\t\t\t}\n\t\t\t\tprint STDERR \" $elem->[1]\\n\";\n\t\t\t}\n\t\t}\n\t' \"$topic\" \"$not_in_next\" \"$not_in_master\"\n\texit 1\nfi\n\n<<\\DOC_END\n\nThis sample hook safeguards topic branches that have been\npublished from being rewound.\n\nThe workflow assumed here is:\n\n * Once a topic branch forks from \"master\", \"master\" is never\n   merged into it again (either directly or indirectly).\n\n * Once a topic branch is fully cooked and merged into \"master\",\n   it is deleted.  If you need to build on top of it to correct\n   earlier mistakes, a new topic branch is created by forking at\n   the tip of the \"master\".  This is not strictly necessary, but\n   it makes it easier to keep your history simple.\n\n * Whenever you need to test or publish your changes to topic\n   branches, merge them into \"next\" branch.\n\nThe script, being an example, hardcodes the publish branch name\nto be \"next\", but it is trivial to make it configurable via\n$GIT_DIR/config mechanism.\n\nWith this workflow, you would want to know:\n\n(1) ... if a topic branch has ever been merged to \"next\".  Young\n    topic branches can have stupid mistakes you would rather\n    clean up before publishing, and things that have not been\n    merged into other branches can be easily rebased without\n    affecting other people.  But once it is published, you would\n    not want to rewind it.\n\n(2) ... if a topic branch has been fully merged to \"master\".\n    Then you can delete it.  More importantly, you should not\n    build on top of it -- other people may already want to\n    change things related to the topic as patches against your\n    \"master\", so if you need further changes, it is better to\n    fork the topic (perhaps with the same name) afresh from the\n    tip of \"master\".\n\nLet's look at this example:\n\n\t\t   o---o---o---o---o---o---o---o---o---o \"next\"\n\t\t  /       /           /           /\n\t\t /   a---a---b A     /           /\n\t\t/   /               /           /\n\t       /   /   c---c---c---c B         /\n\t      /   /   /             \\         /\n\t     /   /   /   b---b C     \\       /\n\t    /   /   /   /             \\     /\n    ---o---o---o---o---o---o---o---o---o---o---o \"master\"\n\n\nA, B and C are topic branches.\n\n * A has one fix since it was merged up to \"next\".\n\n * B has finished.  It has been fully merged up to \"master\" and \"next\",\n   and is ready to be deleted.\n\n * C has not merged to \"next\" at all.\n\nWe would want to allow C to be rebased, refuse A, and encourage\nB to be deleted.\n\nTo compute (1):\n\n\tgit rev-list ^master ^topic next\n\tgit rev-list ^master        next\n\n\tif these match, topic has not merged in next at all.\n\nTo compute (2):\n\n\tgit rev-list master..topic\n\n\tif this is empty, it is fully merged to \"master\".\n\nDOC_END\n"
  },
  {
    "path": ".git/hooks/pre-receive.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to make use of push options.\n# The example simply echoes all push options that start with 'echoback='\n# and rejects all pushes when the \"reject\" push option is used.\n#\n# To enable this hook, rename this file to \"pre-receive\".\n\nif test -n \"$GIT_PUSH_OPTION_COUNT\"\nthen\n\ti=0\n\twhile test \"$i\" -lt \"$GIT_PUSH_OPTION_COUNT\"\n\tdo\n\t\teval \"value=\\$GIT_PUSH_OPTION_$i\"\n\t\tcase \"$value\" in\n\t\techoback=*)\n\t\t\techo \"echo from the pre-receive-hook: ${value#*=}\" >&2\n\t\t\t;;\n\t\treject)\n\t\t\texit 1\n\t\tesac\n\t\ti=$((i + 1))\n\tdone\nfi\n"
  },
  {
    "path": ".git/hooks/prepare-commit-msg.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to prepare the commit log message.\n# Called by \"git commit\" with the name of the file that has the\n# commit message, followed by the description of the commit\n# message's source.  The hook's purpose is to edit the commit\n# message file.  If the hook fails with a non-zero status,\n# the commit is aborted.\n#\n# To enable this hook, rename this file to \"prepare-commit-msg\".\n\n# This hook includes three examples. The first one removes the\n# \"# Please enter the commit message...\" help message.\n#\n# The second includes the output of \"git diff --name-status -r\"\n# into the message, just before the \"git status\" output.  It is\n# commented because it doesn't cope with --amend or with squashed\n# commits.\n#\n# The third example adds a Signed-off-by line to the message, that can\n# still be edited.  This is rarely a good idea.\n\nCOMMIT_MSG_FILE=$1\nCOMMIT_SOURCE=$2\nSHA1=$3\n\n/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' \"$COMMIT_MSG_FILE\"\n\n# case \"$COMMIT_SOURCE,$SHA1\" in\n#  ,|template,)\n#    /usr/bin/perl -i.bak -pe '\n#       print \"\\n\" . `git diff --cached --name-status -r`\n# \t if /^#/ && $first++ == 0' \"$COMMIT_MSG_FILE\" ;;\n#  *) ;;\n# esac\n\n# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\\(.*>\\).*$/Signed-off-by: \\1/p')\n# git interpret-trailers --in-place --trailer \"$SOB\" \"$COMMIT_MSG_FILE\"\n# if test -z \"$COMMIT_SOURCE\"\n# then\n#   /usr/bin/perl -i.bak -pe 'print \"\\n\" if !$first_line++' \"$COMMIT_MSG_FILE\"\n# fi\n"
  },
  {
    "path": ".git/hooks/push-to-checkout.sample",
    "content": "#!/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-pack(1) when it reacts to git\n# push and updates reference(s) in its repository, and when the push\n# tries to update the branch that is currently checked out and the\n# receive.denyCurrentBranch configuration variable is set to\n# updateInstead.\n#\n# By default, such a push is refused if the working tree and the index\n# of the remote repository has any difference from the currently\n# checked out commit; when both the working tree and the index match\n# the current commit, they are updated to match the newly pushed tip\n# of the branch. This hook is to be used to override the default\n# behaviour; however the code below reimplements the default behaviour\n# as a starting point for convenient modification.\n#\n# The hook receives the commit with which the tip of the current\n# branch is going to be updated:\ncommit=$1\n\n# It can exit with a non-zero status to refuse the push (when it does\n# so, it must not modify the index or the working tree).\ndie () {\n\techo >&2 \"$*\"\n\texit 1\n}\n\n# Or it can make any necessary changes to the working tree and to the\n# index to bring them to the desired state when the tip of the current\n# branch is updated to the new commit, and exit with a zero status.\n#\n# For example, the hook can simply run git read-tree -u -m HEAD \"$1\"\n# in order to emulate git fetch that is run in the reverse direction\n# with git push, as the two-tree form of git read-tree -u -m is\n# essentially the same as git switch or git checkout that switches\n# branches while keeping the local changes in the working tree that do\n# not interfere with the difference between the branches.\n\n# The below is a more-or-less exact translation to shell of the C code\n# for the default behaviour for git's push-to-checkout hook defined in\n# the push_to_deploy() function in builtin/receive-pack.c.\n#\n# Note that the hook will be executed from the repository directory,\n# not from the working tree, so if you want to perform operations on\n# the working tree, you will have to adapt your code accordingly, e.g.\n# by adding \"cd ..\" or using relative paths.\n\nif ! git update-index -q --ignore-submodules --refresh\nthen\n\tdie \"Up-to-date check failed\"\nfi\n\nif ! git diff-files --quiet --ignore-submodules --\nthen\n\tdie \"Working directory has unstaged changes\"\nfi\n\n# This is a rough translation of:\n#\n#   head_has_history() ? \"HEAD\" : EMPTY_TREE_SHA1_HEX\nif git cat-file -e HEAD 2>/dev/null\nthen\n\thead=HEAD\nelse\n\thead=$(git hash-object -t tree --stdin </dev/null)\nfi\n\nif ! git diff-index --quiet --cached --ignore-submodules $head --\nthen\n\tdie \"Working directory has staged changes\"\nfi\n\nif ! git read-tree -u -m \"$commit\"\nthen\n\tdie \"Could not update working tree to new HEAD\"\nfi\n"
  },
  {
    "path": ".git/hooks/sendemail-validate.sample",
    "content": "#!/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 hook should exit with non-zero status after issuing an appropriate\n# message if it wants to prevent the email(s) from being sent.\n#\n# To enable this hook, rename this file to \"sendemail-validate\".\n#\n# By default, it will only check that the patch(es) can be applied on top of\n# the default upstream branch without conflicts in a secondary worktree. After\n# validation (successful or not) of the last patch of a series, the worktree\n# will be deleted.\n#\n# The following config variables can be set to change the default remote and\n# remote ref that are used to apply the patches against:\n#\n#   sendemail.validateRemote (default: origin)\n#   sendemail.validateRemoteRef (default: HEAD)\n#\n# Replace the TODO placeholders with appropriate checks according to your\n# needs.\n\nvalidate_cover_letter () {\n\tfile=\"$1\"\n\t# TODO: Replace with appropriate checks (e.g. spell checking).\n\ttrue\n}\n\nvalidate_patch () {\n\tfile=\"$1\"\n\t# Ensure that the patch applies without conflicts.\n\tgit am -3 \"$file\" || return\n\t# TODO: Replace with appropriate checks for this patch\n\t# (e.g. checkpatch.pl).\n\ttrue\n}\n\nvalidate_series () {\n\t# TODO: Replace with appropriate checks for the whole series\n\t# (e.g. quick build, coding style checks, etc.).\n\ttrue\n}\n\n# main -------------------------------------------------------------------------\n\nif test \"$GIT_SENDEMAIL_FILE_COUNTER\" = 1\nthen\n\tremote=$(git config --default origin --get sendemail.validateRemote) &&\n\tref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&\n\tworktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&\n\tgit worktree add -fd --checkout \"$worktree\" \"refs/remotes/$remote/$ref\" &&\n\tgit config --replace-all sendemail.validateWorktree \"$worktree\"\nelse\n\tworktree=$(git config --get sendemail.validateWorktree)\nfi || {\n\techo \"sendemail-validate: error: failed to prepare worktree\" >&2\n\texit 1\n}\n\nunset GIT_DIR GIT_WORK_TREE\ncd \"$worktree\" &&\n\nif grep -q \"^diff --git \" \"$1\"\nthen\n\tvalidate_patch \"$1\"\nelse\n\tvalidate_cover_letter \"$1\"\nfi &&\n\nif test \"$GIT_SENDEMAIL_FILE_COUNTER\" = \"$GIT_SENDEMAIL_FILE_TOTAL\"\nthen\n\tgit config --unset-all sendemail.validateWorktree &&\n\ttrap 'git worktree remove -ff \"$worktree\"' EXIT &&\n\tvalidate_series\nfi\n"
  },
  {
    "path": ".git/hooks/update.sample",
    "content": "#!/bin/sh\n#\n# An example hook script to block unannotated tags from entering.\n# Called by \"git receive-pack\" with arguments: refname sha1-old sha1-new\n#\n# To enable this hook, rename this file to \"update\".\n#\n# Config\n# ------\n# hooks.allowunannotated\n#   This boolean sets whether unannotated tags will be allowed into the\n#   repository.  By default they won't be.\n# hooks.allowdeletetag\n#   This boolean sets whether deleting tags will be allowed in the\n#   repository.  By default they won't be.\n# hooks.allowmodifytag\n#   This boolean sets whether a tag may be modified after creation. By default\n#   it won't be.\n# hooks.allowdeletebranch\n#   This boolean sets whether deleting branches will be allowed in the\n#   repository.  By default they won't be.\n# hooks.denycreatebranch\n#   This boolean sets whether remotely creating branches will be denied\n#   in the repository.  By default this is allowed.\n#\n\n# --- Command line\nrefname=\"$1\"\noldrev=\"$2\"\nnewrev=\"$3\"\n\n# --- Safety check\nif [ -z \"$GIT_DIR\" ]; then\n\techo \"Don't run this script from the command line.\" >&2\n\techo \" (if you want, you could supply GIT_DIR then run\" >&2\n\techo \"  $0 <ref> <oldrev> <newrev>)\" >&2\n\texit 1\nfi\n\nif [ -z \"$refname\" -o -z \"$oldrev\" -o -z \"$newrev\" ]; then\n\techo \"usage: $0 <ref> <oldrev> <newrev>\" >&2\n\texit 1\nfi\n\n# --- Config\nallowunannotated=$(git config --type=bool hooks.allowunannotated)\nallowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)\ndenycreatebranch=$(git config --type=bool hooks.denycreatebranch)\nallowdeletetag=$(git config --type=bool hooks.allowdeletetag)\nallowmodifytag=$(git config --type=bool hooks.allowmodifytag)\n\n# check for no description\nprojectdesc=$(sed -e '1q' \"$GIT_DIR/description\")\ncase \"$projectdesc\" in\n\"Unnamed repository\"* | \"\")\n\techo \"*** Project description file hasn't been set\" >&2\n\texit 1\n\t;;\nesac\n\n# --- Check types\n# if $newrev is 0000...0000, it's a commit to delete a ref.\nzero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')\nif [ \"$newrev\" = \"$zero\" ]; then\n\tnewrev_type=delete\nelse\n\tnewrev_type=$(git cat-file -t $newrev)\nfi\n\ncase \"$refname\",\"$newrev_type\" in\n\trefs/tags/*,commit)\n\t\t# un-annotated tag\n\t\tshort_refname=${refname##refs/tags/}\n\t\tif [ \"$allowunannotated\" != \"true\" ]; then\n\t\t\techo \"*** The un-annotated tag, $short_refname, is not allowed in this repository\" >&2\n\t\t\techo \"*** Use 'git tag [ -a | -s ]' for tags you want to propagate.\" >&2\n\t\t\texit 1\n\t\tfi\n\t\t;;\n\trefs/tags/*,delete)\n\t\t# delete tag\n\t\tif [ \"$allowdeletetag\" != \"true\" ]; then\n\t\t\techo \"*** Deleting a tag is not allowed in this repository\" >&2\n\t\t\texit 1\n\t\tfi\n\t\t;;\n\trefs/tags/*,tag)\n\t\t# annotated tag\n\t\tif [ \"$allowmodifytag\" != \"true\" ] && git rev-parse $refname > /dev/null 2>&1\n\t\tthen\n\t\t\techo \"*** Tag '$refname' already exists.\" >&2\n\t\t\techo \"*** Modifying a tag is not allowed in this repository.\" >&2\n\t\t\texit 1\n\t\tfi\n\t\t;;\n\trefs/heads/*,commit)\n\t\t# branch\n\t\tif [ \"$oldrev\" = \"$zero\" -a \"$denycreatebranch\" = \"true\" ]; then\n\t\t\techo \"*** Creating a branch is not allowed in this repository\" >&2\n\t\t\texit 1\n\t\tfi\n\t\t;;\n\trefs/heads/*,delete)\n\t\t# delete branch\n\t\tif [ \"$allowdeletebranch\" != \"true\" ]; then\n\t\t\techo \"*** Deleting a branch is not allowed in this repository\" >&2\n\t\t\texit 1\n\t\tfi\n\t\t;;\n\trefs/remotes/*,commit)\n\t\t# tracking branch\n\t\t;;\n\trefs/remotes/*,delete)\n\t\t# delete tracking branch\n\t\tif [ \"$allowdeletebranch\" != \"true\" ]; then\n\t\t\techo \"*** Deleting a tracking branch is not allowed in this repository\" >&2\n\t\t\texit 1\n\t\tfi\n\t\t;;\n\t*)\n\t\t# Anything else (is there anything else?)\n\t\techo \"*** Update hook: unknown type of update to ref $refname of type $newrev_type\" >&2\n\t\texit 1\n\t\t;;\nesac\n\n# --- Finished\nexit 0\n"
  },
  {
    "path": ".git/info/exclude",
    "content": "# git ls-files --others --exclude-from=.git/info/exclude\n# Lines that start with '#' are comments.\n# For a project mostly in C, the following would be a good set of\n# exclude patterns (uncomment them if you want to use them):\n# *.[oa]\n# *~\n"
  },
  {
    "path": ".git/logs/HEAD",
    "content": "0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> 1776662781 +0000\tclone: from https://github.com/gilcrest/diygoapi\n"
  },
  {
    "path": ".git/logs/refs/heads/main",
    "content": "0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> 1776662781 +0000\tclone: from https://github.com/gilcrest/diygoapi\n"
  },
  {
    "path": ".git/logs/refs/remotes/origin/HEAD",
    "content": "0000000000000000000000000000000000000000 7a4aacf5c3ee84be2a49944859c55cb586632fc7 appuser <appuser@a0b7d3daa2ed.(none)> 1776662781 +0000\tclone: from https://github.com/gilcrest/diygoapi\n"
  },
  {
    "path": ".git/objects/pack/pack-4cf8052845b33e6cfeabee017e720515ff38889e.promisor",
    "content": "7a4aacf5c3ee84be2a49944859c55cb586632fc7 refs/heads/main\n"
  },
  {
    "path": ".git/packed-refs",
    "content": "# pack-refs with: peeled fully-peeled sorted \n7a4aacf5c3ee84be2a49944859c55cb586632fc7 refs/remotes/origin/main\n"
  },
  {
    "path": ".git/refs/heads/main",
    "content": "7a4aacf5c3ee84be2a49944859c55cb586632fc7\n"
  },
  {
    "path": ".git/refs/remotes/origin/HEAD",
    "content": "ref: refs/remotes/origin/main\n"
  },
  {
    "path": ".git/shallow",
    "content": "7a4aacf5c3ee84be2a49944859c55cb586632fc7\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# Ignore all\n*\n\n# Unignore all with extensions\n!*.*\n\n# Unignore all directories\n!*/\n\n# Unignore Dockerfile\n!Dockerfile\n\n### Above combination will ignore all files without extension ###\n\n# vs code related files\n.vscode/\ntasks.json\n\n# OS generated files #\n######################\n.DS_Store\n\n# Binaries for programs and plugins\n*.exe\n*.dll\n*.so\n*.dylib\n\n# Test binary, build with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# IntelliJ Goland directory and files\n.idea/\n\n# Genesis response file contains sensitive info\n/config/genesis/response.json\n\n# bin directory\n/bin/\n\n# Local configuration (generated and user-specific)\n/config/config.cue\n/config/config.json\n\n# Claude Code local settings (per-developer preferences)\n.claude/settings.local.json"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to [Claude Code](https://claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nDIY 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.).\n\nThe \"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.\n\n## Build & Run Commands\n\n**Build tool**: [Taskfile.dev](https://taskfile.dev/) — tasks defined in `Taskfile.yml`.\n\n| Command | Description |\n|---|---|\n| `task run` | Run the server |\n| `task test` | Run all tests |\n| `task test-verbose` | Run all tests (verbose) |\n| `go test -v -run TestFunctionName ./path/to/package` | Run a single test |\n| `task new-key` | Generate a new encryption key |\n| `task db-init` | Initialize database user, database, and schema via psql |\n| `task db-teardown` | Drop database schema, database, and user via psql |\n| `task db-up` | Run database DDL migrations |\n| `task gen-config` | Generate config from CUE schemas |\n\nDatabase 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`.\n\n--------\n\n## Architecture\n\n### Layer Structure\n\n```\nHTTP Request → server (routes/middleware/handlers) → service (business logic) → sqldb/datastore (data access) → PostgreSQL\n```\n\n- **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/`.\n- **`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\"`).\n- **`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).\n- **`sqldb/datastore/`**: SQL queries generated by [sqlc](https://sqlc.dev/). Type-safe database access via `pgx/v5`. No ORM — raw SQL + sqlc code generation.\n- **`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.\n\n### Middleware Chain\n\nEvery route uses a middleware chain built with `alice`. A typical chain looks like:\n```\nloggerChain → addRequestHandlerPattern → enforceJSONContentType → appHandler → authHandler → authorizeUserHandler → jsonContentTypeResponse → handler\n```\n\n### Error Handling (errs package)\n\nBased 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.\n\nBy 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.\n\nKey types:\n- `Op` — operation trace, builds the error stack as errors propagate up the call stack\n- `Kind` — error classification (`Validation`, `Unauthenticated`, `Unauthorized`, `Internal`, `Database`, etc.)\n- `Code` — machine-readable short code for client error handling\n- `Param` — the parameter related to the error\n- `Realm` — used in the `WWW-Authenticate` response header\n\n`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.\n\n> Note: There are helpers like `errs.MissingField` (returns \"field is required\") and `errs.InputUnwanted` for common validation patterns.\n\n### Authentication & Authorization\n\n- OAuth2 via Google (token validated against Google's OAuth2 v2 API)\n- Bearer token required in `Authorization` header on all requests\n- App authentication via `X-APP-ID` and `X-API-KEY` headers (or falls back to provider Client ID lookup)\n- RBAC: Users have roles, roles have permissions, permissions map to resources (endpoints)\n- Context carries `App`, `User`, and `AuthParams` through the request lifecycle\n\n### Database\n\n- PostgreSQL with `pgx/v5` connection pooling\n- Migrations: SQL files in `scripts/db/migrations/up/` (numbered 000–014)\n- Uses PostgreSQL schemas for tenant isolation (`search_path`)\n\n### Configuration\n\nThe [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`.\n\nThe CUE-based config setup uses a split layout:\n- **`config/cue/schema.cue`** — the shared validation schema (checked into git)\n- **`config/config.cue`** — local config values with credentials (gitignored)\n- **`config/config.json`** — generated output from `task gen-config` (gitignored)\n\nKey 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`\n\n### Logging\n\nStructured 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.\n\n--------\n\n## Key Conventions\n\n- **Testing**: Uses [`frankban/quicktest`](https://github.com/frankban/quicktest) (`c := qt.New(t)`), table-driven subtests\n- **Error ops**: Always `const op errs.Op = \"package/Function\"` as first line, wrap with `errs.E(op, err)`\n- **External IDs**: API responses use external IDs (UUIDs), never internal database IDs. I try to never expose primary keys.\n- **Service constructors**: Services are struct literals with injected dependencies (no constructor functions)\n- **Domain interfaces**: Defined in root package, implemented in `service/`\n\n--------\n\n## Key Terms\n\n- **Person**: A being that has certain capacities or attributes such as reason, morality, consciousness or self-consciousness.\n- **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.\n- **App**: An application that interacts with the system. An App always belongs to just one Org.\n- **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.\n"
  },
  {
    "path": "README.md",
    "content": "# 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 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.).\n\nI 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.\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/gilcrest/diygoapi.svg)](https://pkg.go.dev/github.com/gilcrest/diygoapi) [![Go Report Card](https://goreportcard.com/badge/github.com/gilcrest/diygoapi)](https://goreportcard.com/report/github.com/gilcrest/diygoapi)\n\n## API Walkthrough\n\nThe 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.\n\n## Minimum Requirements\n\n- [Go](https://go.dev/)\n- [PostgreSQL](https://www.postgresql.org/) - Database\n- [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2/web-server) - authentication\n- [Task](https://taskfile.dev/) - task runner for build and script execution\n- [CUE](https://cuelang.org/) - config file generation\n\n--------\n\n## Disclaimer\n\nBriefly, 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.\n\n## Key Terms\n\n- `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.\"\n- `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.\n- `App`: is an application that interacts with the system. An App always belongs to just one Org.\n- `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.\n\n----------\n\n## Getting Started\n\nThe following are basic instructions for getting started. \n\n### Step 1 - Get the code\n\nClone the code:\n\n```shell\n$ git clone https://github.com/gilcrest/diygoapi.git\nCloning into 'diygoapi'...\n```\n\nor use the [Github CLI](https://cli.github.com/) (also written in Go!):\n\n```shell\n$ gh repo clone gilcrest/diygoapi\nCloning into 'diygoapi'...\n```\n\n### Step 2 - Authentication and Authorization\n\n#### Authentication\n\nAll 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.\n\nAfter Oauth2 setup with Google, I recommend the [Google Oauth2 Playground](https://developers.google.com/oauthplayground/) to obtain fresh access tokens for testing.\n\nOnce 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.\n\n- If there is no token present, an `HTTP 401 (Unauthorized)` response will be sent and the response body will be empty.\n- 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.\n\n> Note: For more details on the authentication model, see the [Authentication Detail](#authentication-detail) section below.\n\n#### Authorization\n\nIf 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).\n\n_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.\n\nThe `movieAdmin` role is set up to grant access to all resources. It's a demo... so why not?\n\n> Note: For more details on the authorization model, see the [Authorization Detail](#authorization-detail) section below.\n\n--------\n\n### Step 3 - Configuration\n\nAll 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.\n\n> 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.\n\n#### Generate a new encryption key\n\nRegardless 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`:\n\n```shell\n$ task new-key\nKey Ciphertext: [31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06]\n```\n\n> Copy the key ciphertext between the brackets to your clipboard to use in one of the options below\n\n#### Option 1 (Recommended for Local Development) - Config File\n\n> 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).\n\nThe 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.\n\n```json\n{\n    \"default_target\": \"local\",\n    \"targets\": [\n        {\n            \"target\": \"local\",\n            \"server_listener_port\": 8080,\n            \"logger\": {\n                \"min_log_level\": \"trace\",\n                \"log_level\": \"debug\",\n                \"log_error_stack\": false\n            },\n            \"encryption_key\": \"31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06\",\n            \"database\": {\n                \"host\": \"localhost\",\n                \"port\": 5432,\n                \"name\": \"dga_local\",\n                \"user\": \"demo_user\",\n                \"password\": \"REPLACE_ME\",\n                \"search_path\": \"demo\"\n            }\n        }\n    ]\n}\n```\n\n> 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).\n\n##### Generate config file using CUE (Optional)\n\nIf you prefer, you can generate the JSON config file using [CUE](https://cuelang.org/).\n\nThe CUE-based config uses a split layout:\n- **`config/cue/schema.cue`** -- the shared validation schema (checked into git)\n- **`config/config.cue`** -- local config values with credentials (gitignored)\n- **`config/config.json`** -- generated output (gitignored)\n\nEdit 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.\n\nAfter modifying the CUE file, run the following from project root:\n\n```shell\n$ task gen-config\n```\n\nThis should produce the JSON config file mentioned above (at `./config/config.json`).\n\n#### Option 2 - Environment Variables\n\nAs 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:\n\n```bash\n#!/bin/bash\n\n# encryption key\nexport ENCRYPT_KEY=\"31f8cbffe80df0067fbfac4abf0bb76c51d44cb82d2556743e6bf1a5e25d4e06\"\n\n# server listen port\nexport PORT=\"8080\"\n\n# logger environment variables\nexport LOG_LEVEL_MIN=\"trace\"\nexport LOG_LEVEL=\"debug\"\nexport LOG_ERROR_STACK=\"false\"\n\n# Database Environment variables\nexport DB_HOST=\"localhost\"\nexport DB_PORT=\"5432\"\nexport DB_NAME=\"dga_local\"\nexport DB_USER=\"demo_user\"\nexport DB_PASSWORD=\"REPLACE_ME\"\nexport DB_SEARCH_PATH=\"demo\"\n```\n\n#### Option 3 - Command Line Flags\n\nFor 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:\n\n| Flag Name       | Description                                                                                        | Environment Variable | Default   |\n|-----------------|----------------------------------------------------------------------------------------------------|----------------------|-----------|\n| port            | Port the server will listen on                                                                     | PORT                 | 8080      |\n| log-level       | zerolog logging level (debug, info, etc.)                                                          | LOG_LEVEL            | info      |\n| log-level-min   | sets the minimum accepted logging level                                                            | LOG_LEVEL_MIN        | trace     |\n| log-error-stack | If true, log error stacktrace using github.com/pkg/errors, else just log error (includes op stack) | LOG_ERROR_STACK      | false     |\n| db-host         | The host name of the database server.                                                              | DB_HOST              | localhost |\n| db-port         | The port number the database server is listening on.                                               | DB_PORT              | 5432      |\n| db-name         | The database name.                                                                                 | DB_NAME              |           |\n| db-user         | PostgreSQL™ user name to connect as.                                                               | DB_USER              |           |\n| db-password     | Password to be used if the server demands password authentication.                                 | DB_PASSWORD          |           |\n| db-search-path  | Schema search path to be used when connecting.                                                     | DB_SEARCH_PATH       |           |\n| encrypt-key     | Encryption key to be used for all encrypted data.                                                  | ENCRYPT_KEY          |           |\n\nFor example:\n\n```bash\n$ 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\n```\n\n### Step 4 - Database Initialization\n\nThe 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.\n\n> If you want to create an isolated database and schema, you can find examples of doing that at `./scripts/db/db_init.sql`.\n\n> 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.\n\n#### Initialize the Database\n\nDatabase 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:\n\n```shell\n$ task db-init -- --db-admin-config-target local-admin\n```\n\nThis 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`.\n\n#### Run the Database Up Migration\n\nFifteen database migration scripts are run as part of the up migration:\n\n```shell\n$ task db-up\n```\n\n> 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/`.\n\n#### Data Initialization (Genesis)\n\nThere 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.\n\nTo 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`:\n\n```bash\n$ curl --location --request POST 'http://127.0.0.1:8080/api/v1/genesis' \\\n--header 'Content-Type: application/json' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n--data @./config/genesis/request.json\n```\n\nThe request body defines:\n\n- **`user`**: The OAuth2 provider and token for the user calling Genesis (this user becomes the system's first admin).\n- **`org`**: The user-initiated organization and app to create (your organization for interacting with the Movie APIs).\n- **`permissions`**: The set of permissions (resource + operation pairs) to create, e.g. `POST /api/v1/movies`.\n- **`roles`**: The roles to create, each with a list of permissions to assign to it.\n\n> 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.\n\nThis 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.\n\nMost 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.)\n\nGenesis creates the following seed data within a single database transaction:\n\n1. **Principal Org** — The administrative organization, with a \"Developer Dashboard\" app created within it.\n2. **Test Org** — The test organization, with a \"Test App\" and a test user created within it.\n3. **User-Initiated Org** — Your organization (name and description from the request body), with the app you specified created within it.\n4. **Permissions** — All resource/operation pairs from the request (e.g. `GET /api/v1/movies`, `POST /api/v1/movies`).\n5. **Roles** — All roles from the request (e.g. `sysAdmin`, `movieAdmin`), each linked to their specified permissions.\n6. **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.\n\nThe response contains three nodes — `principal`, `test`, and `userInitiated` — each with the org and app details:\n\n```json\n{\n  \"principal\": {\n    \"external_id\": \"qAG9Gn34ruud86a_\",\n    \"name\": \"Principal\",\n    \"kind_description\": \"principal\",\n    \"description\": \"The Principal org represents the first organization created in the database...\",\n    \"app\": {\n      \"external_id\": \"iK6yM7pBTTSXJXCF\",\n      \"name\": \"Developer Dashboard\",\n      \"api_keys\": [{ \"key\": \"j-DZh4olLswgodsPDA2NsA==\", \"deactivation_date\": \"2099-12-31 ...\" }]\n    }\n  },\n  \"test\": {\n    \"external_id\": \"h-o4hvaClqlM0V3d\",\n    \"name\": \"Test Org\",\n    \"kind_description\": \"test\",\n    \"app\": {\n      \"external_id\": \"upbfVbnyqByyuOUs\",\n      \"name\": \"Test App\",\n      \"api_keys\": [{ \"key\": \"sD-i1kYNWtGaFNauXhKZ6A==\", \"deactivation_date\": \"2099-12-31 ...\" }]\n    }\n  },\n  \"userInitiated\": {\n    \"external_id\": \"a2gmurv3P9Ws1ybk\",\n    \"name\": \"Movie Makers Unlimited\",\n    \"kind_description\": \"standard\",\n    \"app\": {\n      \"external_id\": \"zakvyaRpCu4zmt1-\",\n      \"name\": \"Movie Makers App\",\n      \"api_keys\": [{ \"key\": \"2BJd-AYbXJdzHWmiphtxxA==\", \"deactivation_date\": \"2099-12-31 ...\" }]\n    }\n  }\n}\n```\n\n> The full response is saved to `./config/genesis/response.json` and can be retrieved later via `GET /api/v1/genesis`.\n\nMost 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)).\n\n--------\n\n### Step 5 - Run Tests\n\nThe project tests require that Genesis has been run successfully. If all went well in step 4, you can run the following command to validate:\n\n```shell\n$ task test\n```\n\n> Note: Some tests require a running database with Genesis data. Packages without database dependencies can be tested independently.\n\n### Step 6 - Run the Web Server\n\nWith configuration handled in [Step 3](#step-3---configuration), start the web server with Task:\n\n```shell\n$ task run\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"minimum accepted logging level set to trace\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"logging level set to debug\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"log error stack via github.com/pkg/errors set to false\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"sql database opened for localhost on port 5432\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"sql database Ping returned successfully\"}\n{\"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\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"current database user: demo_user\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"current database: dga_local\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"current search_path: demo\"}\n{\"level\":\"info\",\"time\":1675700939,\"severity\":\"INFO\",\"message\":\"server listening on :8080\"}\n```\n\n> 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).\n\n### Step 7 - Send Requests\n\n#### cURL Commands to Call Ping Service\n\nWith 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.\n\nUse [cURL](https://curl.se/) GET request to call `ping`:\n\n```bash\n$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/ping' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>'\n{\"db_up\":true}\n```\n\n#### cURL Commands to Call Movie Services\n\nThe 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`:\n\n- APP ID (x-app-id): `userInitiated.app.external_id`\n- API Key (x-api-key): `userInitiated.app.api_keys[0].key`\n\nThe 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/)\n\n**Create Movie** - use the `POST` HTTP verb at `/api/v1/movies`:\n\n```shell\n$ curl --location --request POST 'http://127.0.0.1:8080/api/v1/movies' \\\n--header 'Content-Type: application/json' \\\n--header 'x-app-id: <REPLACE WITH APP ID>' \\\n--header 'x-api-key: <REPLACE WITH API KEY>' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n--data-raw '{\n    \"title\": \"Repo Man\",\n    \"rated\": \"R\",\n    \"release_date\": \"1984-03-02T00:00:00Z\",\n    \"run_time\": 92,\n    \"director\": \"Alex Cox\",\n    \"writer\": \"Alex Cox\"\n}'\n{\"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\"}\n```\n\n**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.\n\n```bash\n$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/movies/IUAtsOQuLTuQA5OM' \\\n--header 'x-app-id: <REPLACE WITH APP ID>' \\\n--header 'x-api-key: <REPLACE WITH API KEY>' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n{\"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\"}\n```\n\n**Read (All Records)** - use the `GET` HTTP verb at `/api/v1/movies`:\n\n```bash\n$ curl --location --request GET 'http://127.0.0.1:8080/api/v1/movies' \\\n--header 'x-app-id: <REPLACE WITH APP ID>' \\\n--header 'x-api-key: <REPLACE WITH API KEY>' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n```\n\n**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.\n\n```bash\n$ curl --location --request PUT 'http://127.0.0.1:8080/api/v1/movies/IUAtsOQuLTuQA5OM' \\\n--header 'Content-Type: application/json' \\\n--header 'x-app-id: <REPLACE WITH APP ID>' \\\n--header 'x-api-key: <REPLACE WITH API KEY>' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n--data-raw '{\n    \"title\": \"Repo Man\",\n    \"rated\": \"R\",\n    \"release_date\": \"1984-03-02T00:00:00Z\",\n    \"run_time\": 91,\n    \"director\": \"Alex Cox\",\n    \"writer\": \"Alex Cox\"\n}'\n{\"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\"}\n```\n\n**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.\n\n```bash\n$ curl --location --request DELETE 'http://127.0.0.1:8080/api/v1/movies/IUAtsOQuLTuQA5OM' \\\n--header 'x-app-id: <REPLACE WITH APP ID>' \\\n--header 'x-api-key: <REPLACE WITH API KEY>' \\\n--header 'x-auth-provider: google' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n{\"extl_id\":\"IUAtsOQuLTuQA5OM\",\"deleted\":true}\n```\n\n--------\n\n## Project Walkthrough\n\n### Package Layout\n\n![RealWorld Example Applications](media/diygoapi-package-layout.png)\n\nThe 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`.\n\n> `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.\n\n### Errors\n\nHandling 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.\n\n#### Error Requirements\n\nMy requirements for REST API error handling are the following:\n\n- 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.\n- 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.\n- 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:\n\n```json\n{\n    \"error\": {\n        \"kind\": \"input_validation_error\",\n        \"param\": \"director\",\n        \"message\": \"director is required\"\n    }\n}\n```\n\n- 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:\n\n```json\n{\n    \"error\": {\n        \"kind\": \"internal_error\",\n        \"message\": \"internal server error - please contact support\"\n    }\n}\n```\n\nAll 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.\n\n#### Error Implementation\n\nAll 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.\n\n##### Typical Errors\n\nTypical errors raised throughout `diygoapi` are the custom `errs.Error`, which look like:\n\n ```go\n// Error is the type that implements the error interface.\n// It contains a number of fields, each of different type.\n// An Error value may leave some values unset.\ntype Error struct {\n    // Op is the operation being performed, usually the name of the method\n    // being invoked.\n    Op Op\n    // User is the name of the user attempting the operation.\n    User UserName\n    // Kind is the class of error, such as permission failure,\n    // or \"Other\" if its class is unknown or irrelevant.\n    Kind Kind\n    // Param represents the parameter related to the error.\n    Param Parameter\n    // Code is a human-readable, short representation of the error\n    Code Code\n    // Realm is a description of a protected area, used in the WWW-Authenticate header.\n    Realm Realm\n    // The underlying error that triggered this one, if any.\n    Err error\n}\n```\n\nThis 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.\n\nHere is a simple example of creating an `error` using `errs.E`:\n\n```go\nerr := errs.E(\"seems we have an error here\")\n```\n\nWhen 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.\n\nBy 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.\n\n```go\npackage opdemo\n\nimport (\n    \"fmt\"\n\n    \"github.com/gilcrest/diygoapi/errs\"\n)\n\n// IsEven returns an error if the number given is not even\nfunc IsEven(n int) error {\n    const op errs.Op = \"opdemo/IsEven\"\n\n    if n%2 != 0 {\n        return errs.E(op, fmt.Sprintf(\"%d is not even\", n))\n    }\n    return nil\n}\n```\n\nYou can set any of these custom `errs.Error` fields that you like, for example:\n\n```go\nvar released time.Time\nreleased, err = time.Parse(time.RFC3339, r.Released)\nif err != nil {\n    return nil, errs.E(op, errs.Validation,\n        errs.Code(\"invalid_date_format\"),\n        errs.Parameter(\"release_date\"),\n        err)\n}\n```\n\nAbove, we used `errs.Validation` to set the `errs.Kind` as `Validation`. Valid error `Kind` are:\n\n```go\nconst (\n    Other          Kind = iota // Unclassified error. This value is not printed in the error message.\n    Invalid                    // Invalid operation for this type of item.\n    IO                         // External I/O error such as network failure.\n    Exist                      // Item already exists.\n    NotExist                   // Item does not exist.\n    Private                    // Information withheld.\n    Internal                   // Internal error or inconsistency.\n    BrokenLink                 // Link target does not exist.\n    Database                   // Error from database.\n    Validation                 // Input validation error.\n    Unanticipated              // Unanticipated error.\n    InvalidRequest             // Invalid Request\n    // Unauthenticated is used when a request lacks valid authentication credentials.\n    //\n    // For Unauthenticated errors, the response body will be empty.\n    // The error is logged and http.StatusUnauthorized (401) is sent.\n    Unauthenticated // Unauthenticated Request\n    // Unauthorized is used when a user is authenticated, but is not authorized\n    // to access the resource.\n    //\n    // For Unauthorized errors, the response body should be empty.\n    // The error is logged and http.StatusForbidden (403) is sent.\n    Unauthorized\n)\n```\n\n`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.\n\n`errs.Parameter` represents the parameter that is being validated or has problems, etc.\n\n> 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.\n\nThere 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.\n\nHere is an example in practice:\n\n```go\n// IsValid performs validation of the struct\nfunc (m *Movie) IsValid() error {\n    const op errs.Op = \"diygoapi/Movie.IsValid\"\n\n    switch {\n    case m.Title == \"\":\n        return errs.E(op, errs.Validation, errs.Parameter(\"title\"), errs.MissingField(\"title\"))\n```\n\nThe error message for the above would read **title is required**\n\nThere 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.\n\n###### Typical Error Flow\n\nAs errors created with `errs.E` move up the call stack, the `op` can just be added to the error, like the following:\n\n```go\nfunc outer() error {\n    const op errs.Op = \"opdemo/outer\"\n\n    err := middle()\n    if err != nil {\n        return errs.E(op, err)\n    }\n    return nil\n}\n\nfunc middle() error {\n    err := inner()\n    if err != nil {\n        return errs.E(errs.Op(\"opdemo/middle\"), err)\n    }\n    return nil\n}\n\nfunc inner() error {\n    const op errs.Op = \"opdemo/inner\"\n\n    return errs.E(op, \"seems we have an error here\")\n}\n```\n\n> 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.\n\nIn 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.\n\n##### Handler Flow\n\nAt 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`.\n\nFor example:\n\n```go\nresponse, err := s.CreateMovieService.Create(r.Context(), rb, u)\nif err != nil {\n    errs.HTTPErrorResponse(w, logger, err)\n    return\n}\n```\n\n`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`.\n\n> `return` must be called immediately after `errs.HTTPErrorResponse` to return the error to the client.\n\n##### Typical Error Response\n\nIf an `errs.Error` type is sent to `errs.HTTPErrorResponse`, the function writes the HTTP response body as JSON using the `errs.ErrResponse` struct.\n\n```go\n// ErrResponse is used as the Response Body\ntype ErrResponse struct {\n    Error ServiceError `json:\"error\"`\n}\n\n// ServiceError has fields for Service errors. All fields with no data will be omitted\ntype ServiceError struct {\n    Kind    string `json:\"kind,omitempty\"`\n    Code    string `json:\"code,omitempty\"`\n    Param   string `json:\"param,omitempty\"`\n    Message string `json:\"message,omitempty\"`\n}\n```\n\nWhen the error is returned to the client, the response body JSON looks like the following:\n\n```json\n{\n    \"error\": {\n        \"kind\": \"input validation error\",\n        \"param\": \"title\",\n        \"message\": \"title is required\"\n    }\n}\n```\n\nIn 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.\n\n```json\n{\n   \"level\": \"error\",\n   \"remote_ip\": \"127.0.0.1:60382\",\n   \"user_agent\": \"PostmanRuntime/7.30.1\",\n   \"request_id\": \"cfgihljuns2hhjb77tq0\",\n   \"stack\": [\n      \"diygoapi/Movie.IsValid\",\n      \"service/MovieService.Create\"\n   ],\n   \"error\": \"title is required\",\n   \"http_statuscode\": 400,\n   \"Kind\": \"input validation error\",\n   \"Parameter\": \"title\",\n   \"Code\": \"\",\n   \"time\": 1675700438,\n   \"severity\": \"ERROR\",\n   \"message\": \"error response sent to client\"\n}\n```\n\nIf 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.\n\nThe error log will look like the following (*I cut off parts of the stack for brevity*):\n\n```json\n{\n    \"level\": \"error\",\n    \"ip\": \"127.0.0.1\",\n    \"user_agent\": \"PostmanRuntime/7.26.8\",\n    \"request_id\": \"bvol0mtnf4q269hl3ra0\",\n    \"stack\": [{\n        \"func\": \"E\",\n        \"line\": \"172\",\n        \"source\": \"errs.go\"\n    }, {\n        \"func\": \"(*Movie).SetReleased\",\n        \"line\": \"76\",\n        \"source\": \"movie.go\"\n    }, {\n        \"func\": \"(*MovieController).CreateMovie\",\n        \"line\": \"139\",\n        \"source\": \"create.go\"\n    }, {\n    ...\n    }],\n    \"error\": \"parsing time \\\"1984a-03-02T00:00:00Z\\\" as \\\"2006-01-02T15:04:05Z07:00\\\": cannot parse \\\"a-03-02T00:00:00Z\\\" as \\\"-\\\"\",\n    \"HTTPStatusCode\": 400,\n    \"Kind\": \"input_validation_error\",\n    \"Parameter\": \"release_date\",\n    \"Code\": \"invalid_date_format\",\n    \"time\": 1609650267,\n    \"severity\": \"ERROR\",\n    \"message\": \"Response Error Sent\"\n}\n```\n\n> 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.\n\n##### Internal or Database Error Response\n\nThere 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:\n\n```json\n{\n    \"error\": {\n        \"kind\": \"internal_error\",\n        \"message\": \"internal server error - please contact support\"\n    }\n}\n```\n\n--------\n\n#### Unauthenticated Errors\n\nThe [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.\n\n##### Unauthenticated Error Flow\n\n*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.\n\nThe 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.\n\n```go\n// parseAuthorizationHeader parses/validates the Authorization header and returns an Oauth2 token\nfunc parseAuthorizationHeader(realm string, header http.Header) (*oauth2.Token, error) {\n    const op errs.Op = \"server/parseAuthorizationHeader\"\n\n    // Pull the token from the Authorization header by retrieving the\n    // value from the Header map with \"Authorization\" as the key\n    //\n    // format: Authorization: Bearer\n    headerValue, ok := header[\"Authorization\"]\n    if !ok {\n        return nil, errs.E(op, errs.Unauthenticated, errs.Realm(realm), \"unauthenticated: no Authorization header sent\")\n    }\n...\n```\n\n##### Unauthenticated Error Response\n\nPer 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:\n\n```bash\nHTTP/1.1 401 Unauthorized\nRequest-Id: c30hkvua0brkj8qhk3e0\nWww-Authenticate: Bearer realm=\"diygoapi\"\nDate: Wed, 09 Jun 2021 19:46:07 GMT\nContent-Length: 0\n```\n\n--------\n\n#### Unauthorized Errors\n\nIf the user is not authorized to use the API, an `HTTP 403 (Forbidden)` response will be sent and the response body will be empty.\n\n##### Unauthorized Error Flow\n\n*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.\n\n```go\nreturn errs.E(errs.Unauthorized, fmt.Sprintf(\"user %s does not have %s permission for %s\", adt.User.Username, r.Method, pathTemplate))\n```\n\nPer 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:\n\n```bash\nHTTP/1.1 403 Forbidden\nRequest-Id: c30hp2ma0brkj8qhk3f0\nDate: Wed, 09 Jun 2021 19:54:50 GMT\nContent-Length: 0\n```\n\n### Logging\n\n`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`.\n\n#### Setting Logger State on Startup\n\nWhen starting `diygoapi`, there are several flags which setup the logger:\n\n| Flag Name       | Description                                                                                        | Environment Variable | Default |\n|-----------------|----------------------------------------------------------------------------------------------------|----------------------|---------|\n| log-level       | zerolog logging level (debug, info, etc.)                                                          | LOG_LEVEL            | info    |\n| log-level-min   | sets the minimum accepted logging level                                                            | LOG_LEVEL_MIN        | trace   |\n| log-error-stack | If true, log error stacktrace using github.com/pkg/errors, else just log error (includes op stack) | LOG_ERROR_STACK      | false   |\n\n--------\n\n> 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.\n\nThe `log-level` flag sets the Global logging level for your `zerolog.Logger`.\n\n**zerolog** allows for logging at the following levels (from highest to lowest):\n\n- panic (`zerolog.PanicLevel`, 5)\n- fatal (`zerolog.FatalLevel`, 4)\n- error (`zerolog.ErrorLevel`, 3)\n- warn (`zerolog.WarnLevel`, 2)\n- info (`zerolog.InfoLevel`, 1)\n- debug (`zerolog.DebugLevel`, 0)\n- trace (`zerolog.TraceLevel`, -1)\n\nThe `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`).\n\nThe `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.\n\nAfter parsing the command line flags, `zerolog.Logger` is initialized in `cmd/cmd.go`\n\n```go\n// setup logger with appropriate defaults\nlgr := logger.NewWithGCPHook(os.Stdout, minlvl, true)\n```\n\nand subsequently used to initialize the `server.Server` struct.\n\n```go\n// initialize Server enfolding a http.Server with default timeouts,\n// a mux router and a zerolog.Logger\ns := server.New(http.NewServeMux(), server.NewDriver(), lgr)\n```\n\n#### Logger Setup in Handlers\n\nThe `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.\n\n```go\n// register routes/middleware/handlers to the Server ServeMux\nfunc (s *Server) registerRoutes() {\n\n\t// Match only POST requests at /api/v1/movies\n\t// with Content-Type header = application/json\n\ts.mux.Handle(\"POST /api/v1/movies\",\n\t\ts.loggerChain().\n\t\t\tAppend(s.addRequestHandlerPatternContextHandler).\n\t\t\tAppend(s.enforceJSONContentTypeHandler).\n\t\t\tAppend(s.appHandler).\n\t\t\tAppend(s.authHandler).\n\t\t\tAppend(s.authorizeUserHandler).\n\t\t\tAppend(s.jsonContentTypeResponseHandler).\n\t\t\tThenFunc(s.handleMovieCreate))\n\n...\n```\n\nThe `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.\n\n```go\nfunc (s *Server) loggerChain() alice.Chain {\n\tac := alice.New(hlog.NewHandler(s.Logger),\n\t\thlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {\n\t\t\thlog.FromRequest(r).Info().\n\t\t\t\tStr(\"method\", r.Method).\n\t\t\t\tStringer(\"url\", r.URL).\n\t\t\t\tInt(\"status\", status).\n\t\t\t\tInt(\"size\", size).\n\t\t\t\tDur(\"duration\", duration).\n\t\t\t\tMsg(\"request logged\")\n\t\t}),\n\t\thlog.RemoteAddrHandler(\"remote_ip\"),\n\t\thlog.UserAgentHandler(\"user_agent\"),\n\t\thlog.RefererHandler(\"referer\"),\n\t\thlog.RequestIDHandler(\"request_id\", \"Request-Id\"),\n\t)\n\n\treturn ac\n}\n```\n\nFor every request, you'll get a request log that looks something like the following:\n\n```json\n{\n   \"level\": \"info\",\n   \"remote_ip\": \"127.0.0.1:60382\",\n   \"user_agent\": \"PostmanRuntime/7.30.1\",\n   \"request_id\": \"cfgihljuns2hhjb77tq0\",\n   \"method\": \"POST\",\n   \"url\": \"/api/v1/movies\",\n   \"status\": 400,\n   \"size\": 90,\n   \"duration\": 85.747943,\n   \"time\": 1675700438,\n   \"severity\": \"INFO\",\n   \"message\": \"request logged\"\n}\n```\n\nAll 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:\n\n```json\n{\n    \"level\": \"error\",\n    \"remote_ip\": \"127.0.0.1\",\n    \"user_agent\": \"PostmanRuntime/7.28.0\",\n    \"request_id\": \"c3nppj6a0brt1dho9e2g\",\n    \"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\",\n    \"http_statuscode\": 401,\n    \"realm\": \"diygoapi\",\n    \"time\": 1626315981,\n    \"severity\": \"ERROR\",\n    \"message\": \"Unauthenticated Request\"\n}\n```\n\n> The above error log demonstrates a log for an error with stack trace turned off.\n\nIf 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!\n\n#### Reading and Modifying Logger State\n\nYou can retrieve and update the state of these flags using the `{{base_url}}/api/v1/logger` endpoint.\n\nTo retrieve the current logger state use a `GET` request:\n\n```bash\ncurl --location --request GET 'http://127.0.0.1:8080/api/v1/logger' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>'\n```\n\nand the response will look something like:\n\n```json\n{\n    \"logger_minimum_level\": \"debug\",\n    \"global_log_level\": \"error\",\n    \"log_error_stack\": false\n}\n```\n\nIn order to update the logger state use a `PUT` request:\n\n```bash\ncurl --location --request PUT 'http://127.0.0.1:8080/api/v1/logger' \\\n--header 'Content-Type: application/json' \\\n--header 'Authorization: Bearer <REPLACE WITH ACCESS TOKEN>' \\\n--data-raw '{\n    \"global_log_level\": \"debug\",\n    \"log_error_stack\": \"true\"\n}'\n```\n\nand the response will look something like:\n\n```json\n{\n    \"logger_minimum_level\": \"debug\",\n    \"global_log_level\": \"debug\",\n    \"log_error_stack\": true\n}\n```\n\nThe `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.\n\n#### Authentication Detail\n\n**Authentication** is determined by validating the `App` making the request as well as the `User`.\n\n##### App Authentication Detail\n\nAn `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.\n\nAn `App` has two possible methods of authentication.\n\n1. 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.\n2. 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.\n\n##### User Authentication Detail\n\nUser 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.\n\nIn 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.\n\nAfter 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.\n\nThe Bearer token is used to find the `Auth` object for the user in the database. Searching for the `Auth` object is done as follows:\n- Search the database directly using the Bearer token.\n  - 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.\n  - 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.\n\nIf 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.\n\n#### Authorization Detail\n\nAfter 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:\n\n```go\ns.mux.Handle(\"POST /api/v1/movies\",\n    s.loggerChain().\n        Append(s.addRequestHandlerPatternContextHandler).\n        Append(s.enforceJSONContentTypeHandler).\n        Append(s.appHandler).              // authenticate app\n        Append(s.authHandler).             // authenticate user\n        Append(s.authorizeUserHandler).    // authorize user (RBAC)\n        Append(s.jsonContentTypeResponseHandler).\n        ThenFunc(s.handleMovieCreate))\n```\n\nThe `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.\n\nIf the user is not authorized, an `HTTP 403 (Forbidden)` response is returned with an empty response body.\n\n##### Role Based Access Control\n\nThe RBAC model is built on three core domain types, defined in the root package (`auth.go`):\n\n- **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.\n- **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`.\n- **User**: Roles are assigned at the User level (not the Person level), allowing for fine-grained access control across personas.\n\nThese are linked together through four database tables:\n\n| Table | Purpose |\n|---|---|\n| `permission` | Stores each permission as a unique `(resource, operation)` pair |\n| `role` | Stores roles with a unique `role_cd` |\n| `role_permission` | Junction table linking roles to their permissions |\n| `users_role` | Junction table linking users to roles, **scoped by organization** (`org_id`) |\n\nThe `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.\n\n###### Authorization Query\n\nThe authorization check is a single SQL query (`IsAuthorized`) that joins through the RBAC chain:\n\n```sql\nSELECT ur.user_id\nFROM users_role ur\n         INNER JOIN role_permission rp on rp.role_id = ur.role_id\n         INNER JOIN permission p on p.permission_id = rp.permission_id\nWHERE p.active = true\n  AND p.resource = $1      -- e.g. '/api/v1/movies'\n  AND p.operation = $2     -- e.g. 'POST'\n  AND ur.user_id = $3      -- authenticated user\n  AND ur.org_id = $4;      -- org from authenticated app\n```\n\nThe 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?_\n\nFor 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.\n\nIf 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.\n\n> Note: For details on the 403 response format, see the [Unauthorized Errors](#unauthorized-errors) section above."
  },
  {
    "path": "Taskfile.yml",
    "content": "version: '3'\n\nincludes:\n  cue:\n    taskfile: ./config/cue/Taskfile.yml\n    dir: ./config/cue\n\ntasks:\n  db-init:\n    desc: Initialize database user, database, and schema via psql\n    cmds:\n      - go run ./cmd/dbinit/main.go {{.CLI_ARGS}}\n\n  db-teardown:\n    desc: Drop database schema, database, and user via psql\n    cmds:\n      - go run ./cmd/dbteardown/main.go {{.CLI_ARGS}}\n\n  db-up:\n    desc: Run DDL up migration scripts found in the scripts/db/migrations/up directory\n    cmds:\n      - go run ./cmd/migrations/main.go {{.CLI_ARGS}}\n\n  run:\n    desc: Run the server\n    cmds:\n      - go run ./cmd/diy/main.go\n\n  test:\n    desc: Run all tests\n    cmds:\n      - go test ./...\n\n  test-verbose:\n    desc: Run all tests (verbose)\n    cmds:\n      - go test -v ./...\n\n  new-key:\n    desc: Generate a new encryption key\n    cmds:\n      - go run ./cmd/newkey/main.go\n\n  gcp-deploy:\n    desc: Build and deploy to GCP Cloud Run (not yet implemented)\n    cmds:\n      - echo \"Not yet implemented — GCP deployment helpers need to be redesigned\"\n      - exit 1\n\n  gcp-db-start:\n    desc: Start GCP Cloud SQL instance (not yet implemented)\n    cmds:\n      - echo \"Not yet implemented\"\n      - exit 1\n\n  gcp-db-stop:\n    desc: Stop GCP Cloud SQL instance (not yet implemented)\n    cmds:\n      - echo \"Not yet implemented\"\n      - exit 1\n\n  gen-config:\n    desc: Generate config from CUE schemas\n    cmds:\n      - task: cue:gen-config\n\n  gen-genesis-config:\n    desc: Generate genesis config from CUE files (not yet implemented)\n    cmds:\n      - echo \"Not yet implemented — CUEGenesisPaths helper was removed\"\n      - exit 1\n"
  },
  {
    "path": "app.go",
    "content": "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/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\n// AppServicer manages the retrieval and manipulation of an App\ntype AppServicer interface {\n\tCreate(ctx context.Context, r *CreateAppRequest, adt Audit) (*AppResponse, error)\n\tUpdate(ctx context.Context, r *UpdateAppRequest, adt Audit) (*AppResponse, error)\n}\n\n// APIKeyGenerator creates a random, 128 API key string\ntype APIKeyGenerator interface {\n\tRandomString(n int) (string, error)\n}\n\n// App is an application that interacts with the system\ntype App struct {\n\tID               uuid.UUID\n\tExternalID       secure.Identifier\n\tOrg              *Org\n\tName             string\n\tDescription      string\n\tProvider         Provider\n\tProviderClientID string\n\tAPIKeys          []APIKey\n}\n\n// AddKey validates and adds an API key to the slice of App API keys\nfunc (a *App) AddKey(key APIKey) error {\n\tconst op errs.Op = \"diygoapi/App.AddKey\"\n\n\terr := key.validate()\n\tif err != nil {\n\t\treturn errs.E(op, errs.Internal, err)\n\t}\n\ta.APIKeys = append(a.APIKeys, key)\n\n\treturn nil\n}\n\n// ValidateKey determines if the app has a matching key for the input\n// and if that key is valid\nfunc (a *App) ValidateKey(realm, matchKey string) error {\n\tconst op errs.Op = \"diygoapi/App.ValidateKey\"\n\n\tkey, err := a.matchKey(realm, matchKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = key.validate()\n\tif err != nil {\n\t\treturn errs.E(op, errs.Unauthenticated, errs.Realm(realm), err)\n\t}\n\treturn nil\n}\n\n// MatchKey returns the matching Key given the string, if exists.\n// An error will be sent if no match is found.\nfunc (a *App) matchKey(realm, matchKey string) (APIKey, error) {\n\tconst op errs.Op = \"diygoapi/App.matchKey\"\n\n\tfor _, apiKey := range a.APIKeys {\n\t\tif matchKey == apiKey.Key() {\n\t\t\treturn apiKey, nil\n\t\t}\n\t}\n\treturn APIKey{}, errs.E(op, errs.Unauthenticated, errs.Realm(realm), \"Key does not match any keys for the App\")\n}\n\n// CreateAppRequest is the request struct for Creating an App\ntype CreateAppRequest struct {\n\tName                   string `json:\"name\"`\n\tDescription            string `json:\"description\"`\n\tOauth2Provider         string `json:\"oauth2_provider\"`\n\tOauth2ProviderClientID string `json:\"oauth2_provider_client_id\"`\n}\n\n// Validate determines whether the CreateAppRequest has proper data to be considered valid\nfunc (r CreateAppRequest) Validate() error {\n\tconst op errs.Op = \"diygoapi/CreateAppRequest.Validate\"\n\n\tswitch {\n\tcase r.Name == \"\":\n\t\treturn errs.E(op, errs.Validation, \"app name is required\")\n\tcase r.Description == \"\":\n\t\treturn errs.E(op, errs.Validation, \"app description is required\")\n\tcase r.Oauth2Provider == \"\" && r.Oauth2ProviderClientID != \"\":\n\t\treturn errs.E(op, errs.Validation, \"oAuth2 provider is required when Oauth2 provider client ID is given\")\n\tcase r.Oauth2Provider != \"\" && r.Oauth2ProviderClientID == \"\":\n\t\treturn errs.E(op, errs.Validation, \"oAuth2 provider client ID is required when Oauth2 provider is given\")\n\tcase r.Oauth2Provider != \"\" && r.Oauth2ProviderClientID == \"REPLACE_ME\":\n\t\treturn errs.E(op, errs.Validation, \"oAuth2 provider client ID cannot be REPLACE_ME. \")\n\t}\n\n\t// check if the provider is a known provider\n\tp := ParseProvider(r.Oauth2Provider)\n\n\t// if the provider is unknown, return an error\n\tif p == UnknownProvider {\n\t\treturn errs.E(op, errs.Validation, \"Unknown OAuth2 Provider\")\n\t}\n\n\treturn nil\n}\n\n// UpdateAppRequest is the request struct for Updating an App\ntype UpdateAppRequest struct {\n\tExternalID  string\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// AppResponse is the response struct for an App\ntype AppResponse struct {\n\tExternalID          string           `json:\"external_id\"`\n\tName                string           `json:\"name\"`\n\tDescription         string           `json:\"description\"`\n\tCreateAppExtlID     string           `json:\"create_app_extl_id\"`\n\tCreateUserFirstName string           `json:\"create_user_first_name\"`\n\tCreateUserLastName  string           `json:\"create_user_last_name\"`\n\tCreateDateTime      string           `json:\"create_date_time\"`\n\tUpdateAppExtlID     string           `json:\"update_app_extl_id\"`\n\tUpdateUserFirstName string           `json:\"update_user_first_name\"`\n\tUpdateUserLastName  string           `json:\"update_user_last_name\"`\n\tUpdateDateTime      string           `json:\"update_date_time\"`\n\tAPIKeys             []APIKeyResponse `json:\"api_keys\"`\n}\n\n// APIKeyResponse is the response fields for an API key\ntype APIKeyResponse struct {\n\tKey              string `json:\"key\"`\n\tDeactivationDate string `json:\"deactivation_date\"`\n}\n\n// APIKey is an API key for interacting with the system. The API key string\n// is delivered to the client along with an App ID. The API Key acts as a\n// password for the application.\ntype APIKey struct {\n\t// key: the unencrypted API key string\n\tkey string\n\t// ciphertext: the encrypted API key as []byte\n\tciphertextbytes []byte\n\t// deactivation: the date/time the API key is no longer usable\n\tdeactivation time.Time\n}\n\n// NewAPIKey initializes an APIKey. It generates a random 128-bit (16 byte)\n// base64 encoded string as an API key. The generated key is then encrypted\n// using 256-bit AES-GCM and the encrypted bytes are added to the struct as\n// well.\nfunc NewAPIKey(g APIKeyGenerator, ek *[32]byte, deactivation time.Time) (APIKey, error) {\n\tconst (\n\t\tn  int = 16\n\t\top     = \"diygoapi/NewAPIKey\"\n\t)\n\tvar (\n\t\tk   string\n\t\terr error\n\t)\n\tk, err = g.RandomString(n)\n\tif err != nil {\n\t\treturn APIKey{}, errs.E(op, err)\n\t}\n\n\tvar ctb []byte\n\tctb, err = secure.Encrypt([]byte(k), ek)\n\tif err != nil {\n\t\treturn APIKey{}, err\n\t}\n\n\treturn APIKey{key: k, ciphertextbytes: ctb, deactivation: deactivation}, nil\n}\n\n// NewAPIKeyFromCipher initializes an APIKey given a ciphertext string.\nfunc NewAPIKeyFromCipher(ciphertext string, ek *[32]byte) (APIKey, error) {\n\tconst op errs.Op = \"diygoapi/NewAPIKeyFromCipher\"\n\n\tvar (\n\t\teak []byte\n\t\terr error\n\t)\n\n\t// encrypted api key is stored using hex in db. Decode to get ciphertext bytes.\n\teak, err = hex.DecodeString(ciphertext)\n\tif err != nil {\n\t\treturn APIKey{}, errs.E(op, errs.Internal, err)\n\t}\n\n\tvar apiKey []byte\n\tapiKey, err = secure.Decrypt(eak, ek)\n\tif err != nil {\n\t\treturn APIKey{}, errs.E(op, err)\n\t}\n\n\treturn APIKey{key: string(apiKey), ciphertextbytes: eak}, nil\n}\n\n// Key returns the key for the API key\nfunc (a *APIKey) Key() string {\n\treturn a.key\n}\n\n// Ciphertext returns the hex encoded text of the encrypted cipher bytes for the API key\nfunc (a *APIKey) Ciphertext() string {\n\treturn hex.EncodeToString(a.ciphertextbytes)\n}\n\n// DeactivationDate returns the Deactivation Date for the API key\nfunc (a *APIKey) DeactivationDate() time.Time {\n\treturn a.deactivation\n}\n\n// SetDeactivationDate sets the deactivation date value to AppAPIkey\n// TODO - try SetDeactivationDate as a candidate for generics with 1.18\nfunc (a *APIKey) SetDeactivationDate(t time.Time) {\n\ta.deactivation = t\n}\n\n// SetStringAsDeactivationDate sets the deactivation date value to\n// AppAPIkey given a string in RFC3339 format\nfunc (a *APIKey) SetStringAsDeactivationDate(s string) error {\n\tconst op errs.Op = \"diygoapi/APIKey.SetStringAsDeactivationDate\"\n\n\tt, err := time.Parse(time.RFC3339, s)\n\tif err != nil {\n\t\treturn errs.E(op, errs.Validation, err)\n\t}\n\ta.deactivation = t\n\n\treturn nil\n}\n\nfunc (a *APIKey) validate() error {\n\tconst op errs.Op = \"diygoapi/APIKey.validate\"\n\n\tif a.ciphertextbytes == nil {\n\t\treturn errs.E(op, \"ciphertext must have a value\")\n\t}\n\n\tnow := time.Now()\n\tif a.deactivation.Before(now) {\n\t\treturn errs.E(op, fmt.Sprintf(\"Key Deactivation %s is before current time %s\", a.deactivation.String(), now.String()))\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "app_test.go",
    "content": "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/quicktest\"\n\n\t\"github.com/gilcrest/diygoapi\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\nfunc TestApp_AddKey(t *testing.T) {\n\tt.Run(\"add valid key\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\to := &diygoapi.Org{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tName:        \"app test\",\n\t\t\tDescription: \"test app\",\n\t\t\tKind:        &diygoapi.OrgKind{},\n\t\t}\n\n\t\ta := diygoapi.App{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tOrg:         o,\n\t\t\tName:        \"\",\n\t\t\tDescription: \"\",\n\t\t\tAPIKeys:     nil,\n\t\t}\n\t\tvar (\n\t\t\tek  *[32]byte\n\t\t\terr error\n\t\t)\n\t\tek, err = secure.NewEncryptionKey()\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tvar key diygoapi.APIKey\n\t\tkey, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*100))\n\t\tc.Assert(err, qt.IsNil)\n\n\t\terr = a.AddKey(key)\n\t\tc.Assert(err, qt.IsNil)\n\t\tc.Assert(len(a.APIKeys), qt.Equals, 1)\n\t})\n\tt.Run(\"add expired key\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\to := &diygoapi.Org{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tName:        \"app test\",\n\t\t\tDescription: \"test app\",\n\t\t\tKind:        &diygoapi.OrgKind{},\n\t\t}\n\n\t\ta := diygoapi.App{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tOrg:         o,\n\t\t\tName:        \"\",\n\t\t\tDescription: \"\",\n\t\t\tAPIKeys:     nil,\n\t\t}\n\t\tvar (\n\t\t\tek  *[32]byte\n\t\t\terr error\n\t\t)\n\t\tek, err = secure.NewEncryptionKey()\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tvar key diygoapi.APIKey\n\t\tkey, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*-100))\n\t\tc.Assert(err, qt.IsNil)\n\n\t\terr = a.AddKey(key)\n\t\tc.Assert(err, qt.Not(qt.IsNil))\n\t})\n}\n\nfunc TestApp_ValidateKey(t *testing.T) {\n\tt.Run(\"valid key\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\to := &diygoapi.Org{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tName:        \"app test\",\n\t\t\tDescription: \"test app\",\n\t\t\tKind:        &diygoapi.OrgKind{},\n\t\t}\n\n\t\ta := diygoapi.App{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tOrg:         o,\n\t\t\tName:        \"\",\n\t\t\tDescription: \"\",\n\t\t\tAPIKeys:     nil,\n\t\t}\n\t\tvar (\n\t\t\tek  *[32]byte\n\t\t\terr error\n\t\t)\n\t\tek, err = secure.NewEncryptionKey()\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tvar key diygoapi.APIKey\n\t\tkey, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*100))\n\t\tc.Assert(err, qt.IsNil)\n\n\t\terr = a.AddKey(key)\n\t\tc.Assert(err, qt.IsNil)\n\n\t\terr = a.ValidateKey(\"deep in the realm\", key.Key())\n\t\tc.Assert(err, qt.IsNil)\n\t})\n\tt.Run(\"key does not match\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\to := &diygoapi.Org{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tName:        \"app test\",\n\t\t\tDescription: \"test app\",\n\t\t\tKind:        &diygoapi.OrgKind{},\n\t\t}\n\n\t\ta := diygoapi.App{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tOrg:         o,\n\t\t\tName:        \"\",\n\t\t\tDescription: \"\",\n\t\t\tAPIKeys:     nil,\n\t\t}\n\n\t\terr := a.ValidateKey(\"deep in the realm\", \"badkey\")\n\t\tc.Assert(err, qt.ErrorMatches, \"Key does not match any keys for the App\")\n\t})\n\tt.Run(\"key matches but invalid\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\to := &diygoapi.Org{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tName:        \"app test\",\n\t\t\tDescription: \"test app\",\n\t\t\tKind:        &diygoapi.OrgKind{},\n\t\t}\n\n\t\ta := diygoapi.App{\n\t\t\tID:          uuid.New(),\n\t\t\tExternalID:  secure.NewID(),\n\t\t\tOrg:         o,\n\t\t\tName:        \"\",\n\t\t\tDescription: \"\",\n\t\t\tAPIKeys:     nil,\n\t\t}\n\t\tvar (\n\t\t\tek  *[32]byte\n\t\t\terr error\n\t\t)\n\t\tek, err = secure.NewEncryptionKey()\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tvar key diygoapi.APIKey\n\t\tkey, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Now().Add(time.Hour*-100))\n\t\tc.Assert(err, qt.IsNil)\n\n\t\ta.APIKeys = append(a.APIKeys, key)\n\n\t\terr = a.ValidateKey(\"deep in the realm\", key.Key())\n\t\tc.Assert(err, qt.ErrorMatches, fmt.Sprintf(\"Key Deactivation %s is before current time .*\", key.DeactivationDate().String()))\n\t})\n}\n\nfunc TestNewAPIKey(t *testing.T) {\n\tt.Run(\"key byte length\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\t\tvar (\n\t\t\tek  *[32]byte\n\t\t\terr error\n\t\t)\n\t\tek, err = secure.NewEncryptionKey()\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tvar key diygoapi.APIKey\n\t\tkey, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Date(2999, 12, 31, 0, 0, 0, 0, time.UTC))\n\t\tc.Assert(err, qt.IsNil)\n\n\t\t// decode base64\n\t\tvar keyBytes []byte\n\t\tkeyBytes, err = base64.URLEncoding.DecodeString(key.Key())\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tc.Assert(len(keyBytes), qt.Equals, 16, qt.Commentf(\"assure key byte length is always 16 (128-bit)\"))\n\t})\n\tt.Run(\"decrypt key\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\t\tvar (\n\t\t\tek  *[32]byte\n\t\t\terr error\n\t\t)\n\t\tek, err = secure.NewEncryptionKey()\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tvar key diygoapi.APIKey\n\t\tkey, err = diygoapi.NewAPIKey(secure.RandomGenerator{}, ek, time.Date(2999, 12, 31, 0, 0, 0, 0, time.UTC))\n\t\tc.Assert(err, qt.IsNil)\n\n\t\t// Ciphertext method returns the bytes as a hex encoded string.\n\t\t// decode to get the bytes\n\t\tvar cb []byte\n\t\tcb, err = hex.DecodeString(key.Ciphertext())\n\t\tc.Assert(err, qt.IsNil)\n\n\t\t// decrypt the encrypted key\n\t\tvar apiKey []byte\n\t\tapiKey, err = secure.Decrypt(cb, ek)\n\t\tc.Assert(err, qt.IsNil)\n\n\t\tc.Assert(string(apiKey), qt.Equals, key.Key(), qt.Commentf(\"ensure decrypted key matches key string\"))\n\t})\n}\n"
  },
  {
    "path": "auth.go",
    "content": "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\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\nconst (\n\t// AppIDHeaderKey is the App ID header key\n\tAppIDHeaderKey string = \"X-APP-ID\"\n\t// ApiKeyHeaderKey is the API key header key\n\tApiKeyHeaderKey string = \"X-API-KEY\"\n\t// AuthProviderHeaderKey is the Authorization provider header key\n\tAuthProviderHeaderKey string = \"X-AUTH-PROVIDER\"\n)\n\n// PermissionServicer allows for creating, updating, reading and deleting a Permission\ntype PermissionServicer interface {\n\tCreate(ctx context.Context, r *CreatePermissionRequest, adt Audit) (*PermissionResponse, error)\n\tFindAll(ctx context.Context) ([]*PermissionResponse, error)\n\tDelete(ctx context.Context, extlID string) (DeleteResponse, error)\n}\n\n// RoleServicer allows for creating, updating, reading and deleting a Role\n// as well as assigning permissions and users to it.\ntype RoleServicer interface {\n\tCreate(ctx context.Context, r *CreateRoleRequest, adt Audit) (*RoleResponse, error)\n}\n\n// AuthenticationServicer represents a service for managing authentication.\n//\n// For this project, Oauth2 is used for user authentication. It is assumed\n// that the actual user interaction is being orchestrated externally and\n// the server endpoints are being called after an access token has already\n// been retrieved from an authentication provider.\n//\n// In addition, this project provides for a custom application authentication.\n// If an endpoint request is sent using application credentials, then those\n// will be used. If none are sent, then the client id from the access token\n// must be registered in the system and that is used as the calling application.\n// The latter is likely the more common use case.\ntype AuthenticationServicer interface {\n\n\t// SelfRegister is used for first-time registration of a Person/User\n\t// in the system (associated with an Organization). This is \"self\n\t// registration\" as opposed to one person registering another person.\n\tSelfRegister(ctx context.Context, params *AuthenticationParams) (ur *UserResponse, err error)\n\n\t// FindExistingAuth looks up a User given a Provider and Access Token.\n\t// If a User is not found, an error is returned.\n\tFindExistingAuth(r *http.Request, realm string) (Auth, error)\n\n\t// FindAppByProviderClientID Finds an App given a Provider Client ID as part\n\t// of an Auth object.\n\tFindAppByProviderClientID(ctx context.Context, realm string, auth Auth) (a *App, err error)\n\n\t// DetermineAppContext checks to see if the request already has an app as part of\n\t// if it does, use that app as the app for session, if it does not, determine the\n\t// app based on the user's provider client ID. In either case, return a new context\n\t// with an app. If there is no app to be found for either, return an error.\n\tDetermineAppContext(ctx context.Context, auth Auth, realm string) (context.Context, error)\n\n\t// FindAppByAPIKey finds an app given its External ID and determines\n\t// if the given API key is a valid key for it. It is used as part of\n\t// app authentication.\n\tFindAppByAPIKey(r *http.Request, realm string) (*App, error)\n\n\t// AuthenticationParamExchange returns a ProviderInfo struct\n\t// after calling remote Oauth2 provider.\n\tAuthenticationParamExchange(ctx context.Context, params *AuthenticationParams) (*ProviderInfo, error)\n\n\t// NewAuthenticationParams parses the provider and authorization\n\t// headers and returns AuthenticationParams based on the results\n\tNewAuthenticationParams(r *http.Request, realm string) (*AuthenticationParams, error)\n}\n\n// AuthorizationServicer represents a service for managing authorization.\ntype AuthorizationServicer interface {\n\tAuthorize(r *http.Request, lgr zerolog.Logger, adt Audit) error\n}\n\n// TokenExchanger exchanges an oauth2.Token for a ProviderUserInfo\n// struct populated with information retrieved from an authentication provider.\ntype TokenExchanger interface {\n\tExchange(ctx context.Context, realm string, provider Provider, token *oauth2.Token) (*ProviderInfo, error)\n}\n\n// BearerTokenType is used in authorization to access a resource\nconst BearerTokenType string = \"Bearer\"\n\n// Provider defines the provider of authorization (Google, GitHub, Apple, auth0, etc.).\n//\n// Only Google is used currently.\ntype Provider uint8\n\n// Provider of authorization\n//\n// The app uses Oauth2 to authorize users with one of the following Providers\nconst (\n\tUnknownProvider Provider = iota\n\tGoogle                   // Google\n)\n\nfunc (p Provider) String() string {\n\tswitch p {\n\tcase Google:\n\t\treturn \"google\"\n\tdefault:\n\t\treturn \"unknown_provider\"\n\t}\n}\n\n// ParseProvider initializes a Provider given a case-insensitive string\nfunc ParseProvider(s string) Provider {\n\tswitch strings.ToLower(s) {\n\tcase \"google\":\n\t\treturn Google\n\t}\n\treturn UnknownProvider\n}\n\n// ProviderInfo contains information returned from an authorization provider\ntype ProviderInfo struct {\n\tProvider  Provider\n\tTokenInfo *ProviderTokenInfo\n\tUserInfo  *ProviderUserInfo\n}\n\n// ProviderTokenInfo contains non-user information gleaned from the\n// Oauth2 provider's access token and subsequent calls to get information\n// about a person using it. See ProviderUserInfo for user information.\ntype ProviderTokenInfo struct {\n\n\t// Token is the Oauth2 token. For inbound requests, only the\n\t// Access Token is given in the Authorization header, so the\n\t// other details (Refresh Token, Token Type, Expiry) must be\n\t// retrieved from a 3rd party service. The token's Expiry is\n\t// a calculated time of expiration (estimated). This is a moving\n\t// target as some providers send the actual time of expiration,\n\t// others just send seconds until expiration, which means it's\n\t// a calculation and won't have perfect precision.\n\tToken *oauth2.Token\n\n\t// Client ID: External ID representing the Oauth2 client which\n\t// authenticated the user.\n\tClientID string\n\n\t// Scope: The space separated list of scopes granted to this token.\n\tScope string\n\n\t// Audience: Who is the intended audience for this token. In general the\n\t// same as issued_to.\n\tAudience string `json:\"audience,omitempty\"`\n\n\t// IssuedTo: To whom was the token issued to. In general the same as\n\t// audience.\n\tIssuedTo string `json:\"issued_to,omitempty\"`\n}\n\n// ProviderUserInfo contains common fields from the various Oauth2 providers.\n// Currently only using Google, so looks a lot like Google's.\ntype ProviderUserInfo struct {\n\t// ID: The obfuscated ID of the user assigned by the authentication provider.\n\tExternalID string\n\n\t// Email: The user's email address.\n\tEmail string\n\n\t// VerifiedEmail: Boolean flag which is true if the email address is\n\t// verified. Present only if the email scope is present in the request.\n\tVerifiedEmail bool\n\n\t// NamePrefix: The name prefix for the Profile (e.g. Mx., Ms., Mr., etc.)\n\tNamePrefix string\n\n\t// MiddleName: The person's middle name.\n\tMiddleName string\n\n\t// FirstName: The user's first name.\n\tFirstName string\n\n\t// FamilyName: The user's last name.\n\tLastName string\n\n\t// FullName: The user's full name.\n\tFullName string\n\n\t// NameSuffix: The name suffix for the person's name (e.g. \"PhD\", \"CCNA\", \"OBE\").\n\t// Other examples include generational designations like \"Sr.\" and \"Jr.\" and \"I\", \"II\", \"III\", etc.\n\tNameSuffix string\n\n\t// Nickname: The person's nickname\n\tNickname string\n\n\t// Gender: The user's gender. TODO - setup Gender properly. not binary.\n\tGender string\n\n\t// BirthDate: The full birthdate of a person (e.g. Dec 18, 1953)\n\tBirthDate time.Time\n\n\t// Hd: The hosted domain e.g. example.com if the user is Google apps\n\t// user.\n\tHostedDomain string\n\n\t// Link: URL of the profile page.\n\tProfileLink string\n\n\t// Locale: The user's preferred locale.\n\tLocale string\n\n\t// Picture: URL of the user's picture image.\n\tPicture string\n}\n\n// Auth represents a user's authorization in the database. It captures\n// the provider Oauth2 credentials. Users are linked to a Person.\n// A single Person could authenticate through multiple providers.\ntype Auth struct {\n\t// ID is the unique identifier for authorization record in database\n\tID uuid.UUID\n\n\t// User is the unique user associated to the authorization record.\n\t//\n\t// A Person can have one or more methods of authentication, however,\n\t// only one per authorization provider is allowed per User.\n\tUser *User\n\n\t// Provider is the authentication provider\n\tProvider Provider\n\n\t// ProviderClientID is the external ID representing the Oauth2 client which\n\t// authenticated the user.\n\tProviderClientID string\n\n\t// ProviderPersonID is the authentication provider's unique person/user ID.\n\tProviderPersonID string\n\n\t// Provider Access Token\n\tProviderAccessToken string\n\n\t// Provider Access Token Expiration Date/Time\n\tProviderAccessTokenExpiry time.Time\n\n\t// Provider Refresh Token\n\tProviderRefreshToken string\n}\n\n// Permission stores an approval of a mode of access to a resource.\ntype Permission struct {\n\t// ID is the unique ID for the Permission.\n\tID uuid.UUID\n\t// ExternalID is the unique External ID to be given to outside callers.\n\tExternalID secure.Identifier\n\t// Resource is a human-readable string which represents a resource (e.g. an HTTP route or document, etc.).\n\tResource string\n\t// Operation represents the action taken on the resource (e.g. POST, GET, edit, etc.)\n\tOperation string\n\t// Description is what the permission is granting, e.g. \"grants ability to edit a billing document\".\n\tDescription string\n\t// Active is a boolean denoting whether the permission is active (true) or not (false).\n\tActive bool\n}\n\n// Validate determines if the Permission is valid\nfunc (p Permission) Validate() error {\n\tconst op errs.Op = \"diygoapi/Permission.Validate\"\n\n\tswitch {\n\tcase p.ID == uuid.Nil:\n\t\treturn errs.E(op, errs.Validation, \"ID is required\")\n\tcase p.ExternalID.String() == \"\":\n\t\treturn errs.E(op, errs.Validation, \"External ID is required\")\n\tcase p.Resource == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Resource is required\")\n\tcase p.Description == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Description is required\")\n\t}\n\treturn nil\n}\n\n// CreatePermissionRequest is the request struct for creating a permission\ntype CreatePermissionRequest struct {\n\t// A human-readable string which represents a resource (e.g. an HTTP route or document, etc.).\n\tResource string `json:\"resource\"`\n\t// A string representing the action taken on the resource (e.g. POST, GET, edit, etc.)\n\tOperation string `json:\"operation\"`\n\t// A description of what the permission is granting, e.g. \"grants ability to edit a billing document\".\n\tDescription string `json:\"description\"`\n\t// A boolean denoting whether the permission is active (true) or not (false).\n\tActive bool `json:\"active\"`\n}\n\n// FindPermissionRequest is the response struct for finding a permission\ntype FindPermissionRequest struct {\n\t// Unique External ID to be given to outside callers.\n\tExternalID string `json:\"external_id\"`\n\t// A human-readable string which represents a resource (e.g. an HTTP route or document, etc.).\n\tResource string `json:\"resource\"`\n\t// A string representing the action taken on the resource (e.g. POST, GET, edit, etc.)\n\tOperation string `json:\"operation\"`\n}\n\n// PermissionResponse is the response struct for a permission\ntype PermissionResponse struct {\n\t// Unique External ID to be given to outside callers.\n\tExternalID string `json:\"external_id\"`\n\t// A human-readable string which represents a resource (e.g. an HTTP route or document, etc.).\n\tResource string `json:\"resource\"`\n\t// A string representing the action taken on the resource (e.g. POST, GET, edit, etc.)\n\tOperation string `json:\"operation\"`\n\t// A description of what the permission is granting, e.g. \"grants ability to edit a billing document\".\n\tDescription string `json:\"description\"`\n\t// A boolean denoting whether the permission is active (true) or not (false).\n\tActive bool `json:\"active\"`\n}\n\n// Role is a job function or title which defines an authority level.\ntype Role struct {\n\t// The unique ID for the Role.\n\tID uuid.UUID\n\t// Unique External ID to be given to outside callers.\n\tExternalID secure.Identifier\n\t// A human-readable code which represents the role.\n\tCode string\n\t// A longer description of the role.\n\tDescription string\n\t// A boolean denoting whether the role is active (true) or not (false).\n\tActive bool\n\t// Permissions is the list of permissions allowed for the role.\n\tPermissions []*Permission\n}\n\n// Validate determines if the Role is valid.\nfunc (r Role) Validate() error {\n\tconst op errs.Op = \"diygoapi/Role.Validate\"\n\n\tswitch {\n\tcase r.ID == uuid.Nil:\n\t\treturn errs.E(op, errs.Validation, \"ID is required\")\n\tcase r.ExternalID.String() == \"\":\n\t\treturn errs.E(op, errs.Validation, \"External ID is required\")\n\tcase r.Code == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Code is required\")\n\tcase r.Description == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Description is required\")\n\t}\n\n\treturn nil\n}\n\n// CreateRoleRequest is the request struct for creating a role\ntype CreateRoleRequest struct {\n\t// A human-readable code which represents the role.\n\tCode string `json:\"role_cd\"`\n\t// A longer description of the role.\n\tDescription string `json:\"role_description\"`\n\t// A boolean denoting whether the role is active (true) or not (false).\n\tActive bool `json:\"active\"`\n\t// The list of permissions to be given to the role\n\tPermissions []*FindPermissionRequest\n}\n\n// RoleResponse is the response struct for a Role.\ntype RoleResponse struct {\n\t// Unique External ID to be given to outside callers.\n\tExternalID string `json:\"external_id\"`\n\t// A human-readable code which represents the role.\n\tCode string `json:\"role_cd\"`\n\t// A longer description of the role.\n\tDescription string `json:\"role_description\"`\n\t// A boolean denoting whether the role is active (true) or not (false).\n\tActive bool `json:\"active\"`\n\t// Permissions is the list of permissions allowed for the role.\n\tPermissions []*Permission\n}\n\n// AuthenticationParams is the parameters needed for authenticating a User.\ntype AuthenticationParams struct {\n\t// Realm is a description of a protected area, used in the WWW-Authenticate header.\n\tRealm string\n\t// Provider is the authentication provider.\n\tProvider Provider\n\t// Token is the authentication token sent as part of Oauth2.\n\tToken *oauth2.Token\n}\n"
  },
  {
    "path": "auth_test.go",
    "content": "package diygoapi_test\n\nimport (\n\t\"github.com/gilcrest/diygoapi\"\n\t\"testing\"\n\n\tqt \"github.com/frankban/quicktest\"\n)\n\nfunc TestNewProvider(t *testing.T) {\n\tt.Run(\"google\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\t\tp := diygoapi.ParseProvider(\"GoOgLe\")\n\t\tc.Assert(p, qt.Equals, diygoapi.Google)\n\t})\n\tt.Run(\"unknown\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\t\tp := diygoapi.ParseProvider(\"anything else!\")\n\t\tc.Assert(p, qt.Equals, diygoapi.UnknownProvider)\n\t})\n}\n\nfunc TestProvider_String(t *testing.T) {\n\tt.Run(\"google\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\t\tp := diygoapi.ParseProvider(\"GoOgLe\")\n\t\tprovider := p.String()\n\t\tc.Assert(provider, qt.Equals, \"google\")\n\t})\n\tt.Run(\"unknown\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\t\tp := diygoapi.ParseProvider(\"anything else\")\n\t\tprovider := p.String()\n\t\tc.Assert(provider, qt.Equals, \"unknown_provider\")\n\t})\n}\n"
  },
  {
    "path": "context.go",
    "content": "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 string\n\nconst (\n\thandlerPatternKey    contextKey = \"handlerPattern\"\n\tappContextKey        contextKey = \"app\"\n\tcontextKeyUser       contextKey = \"user\"\n\tauthParamsContextKey contextKey = \"authParams\"\n)\n\n// NewContextWithRequestHandlerPattern returns a new context with the given Handler pattern\nfunc NewContextWithRequestHandlerPattern(ctx context.Context, pattern string) context.Context {\n\treturn context.WithValue(ctx, handlerPatternKey, pattern)\n}\n\n// HandlerPatternFromRequest is a helper function which returns the handler pattern from the\n// request context.\nfunc HandlerPatternFromRequest(r *http.Request) (string, error) {\n\tconst op errs.Op = \"diygoapi/HandlerPatternFromRequest\"\n\n\tpattern, err := RequestHandlerPatternFromContext(r.Context())\n\tif err != nil {\n\t\treturn \"\", errs.E(op, err)\n\t}\n\n\treturn pattern, nil\n}\n\n// RequestHandlerPatternFromContext returns a Handler Pattern from the given context\nfunc RequestHandlerPatternFromContext(ctx context.Context) (string, error) {\n\tconst op errs.Op = \"diygoapi/RequestHandlerPatternFromContext\"\n\n\tpattern, ok := ctx.Value(handlerPatternKey).(string)\n\tif !ok {\n\t\treturn \"\", errs.E(op, errs.NotExist, \"handler pattern not set to context\")\n\t}\n\n\tif pattern == \"\" {\n\t\treturn \"\", errs.E(op, errs.NotExist, \"handler pattern not set to context (empty string)\")\n\t}\n\n\treturn pattern, nil\n}\n\n// NewContextWithApp returns a new context with the given App\nfunc NewContextWithApp(ctx context.Context, a *App) context.Context {\n\treturn context.WithValue(ctx, appContextKey, a)\n}\n\n// AppFromRequest is a helper function which returns the App from the\n// request context.\nfunc AppFromRequest(r *http.Request) (*App, error) {\n\tconst op errs.Op = \"diygoapi/AppFromRequest\"\n\n\tapp, err := AppFromContext(r.Context())\n\tif err != nil {\n\t\treturn nil, errs.E(op, err)\n\t}\n\n\treturn app, nil\n}\n\n// AppFromContext returns the App from the given context\nfunc AppFromContext(ctx context.Context) (*App, error) {\n\tconst op errs.Op = \"diygoapi/AppFromContext\"\n\n\ta, ok := ctx.Value(appContextKey).(*App)\n\tif !ok {\n\t\treturn a, errs.E(op, errs.NotExist, \"App not set to context\")\n\t}\n\treturn a, nil\n}\n\n// NewContextWithUser returns a new context with the given User\nfunc NewContextWithUser(ctx context.Context, u *User) context.Context {\n\treturn context.WithValue(ctx, contextKeyUser, u)\n}\n\n// UserFromRequest returns the User from the request context\nfunc UserFromRequest(r *http.Request) (u *User, err error) {\n\tconst op errs.Op = \"diygoapi/UserFromRequest\"\n\n\tu, err = UserFromContext(r.Context())\n\tif err != nil {\n\t\treturn nil, errs.E(op, err)\n\t}\n\n\treturn u, nil\n}\n\n// UserFromContext returns the User from the given Context\nfunc UserFromContext(ctx context.Context) (*User, error) {\n\tconst op errs.Op = \"diygoapi/UserFromContext\"\n\n\tu, ok := ctx.Value(contextKeyUser).(*User)\n\tif !ok {\n\t\treturn nil, errs.E(op, errs.NotExist, \"User not set properly to context\")\n\t}\n\treturn u, nil\n}\n\n// AuditFromRequest is a convenience function that sets up an Audit\n// struct from the App and User set to the request context.\n// The moment is also set to time.Now\nfunc AuditFromRequest(r *http.Request) (adt Audit, err error) {\n\tconst op errs.Op = \"diygoapi/AuditFromRequest\"\n\n\tvar a *App\n\ta, err = AppFromRequest(r)\n\tif err != nil {\n\t\treturn Audit{}, errs.E(op, err)\n\t}\n\n\tvar u *User\n\tu, err = UserFromRequest(r)\n\tif err != nil {\n\t\treturn Audit{}, errs.E(op, err)\n\t}\n\n\tadt.App = a\n\tadt.User = u\n\tadt.Moment = time.Now()\n\n\treturn adt, nil\n}\n\n// NewContextWithAuthParams returns a new context with the given AuthenticationParams\nfunc NewContextWithAuthParams(ctx context.Context, ap *AuthenticationParams) context.Context {\n\treturn context.WithValue(ctx, authParamsContextKey, ap)\n}\n\n// AuthParamsFromContext returns the AuthenticationParams from the given context\nfunc AuthParamsFromContext(ctx context.Context) (*AuthenticationParams, error) {\n\tconst op errs.Op = \"diygoapi/AuthParamsFromContext\"\n\n\ta, ok := ctx.Value(authParamsContextKey).(*AuthenticationParams)\n\tif !ok {\n\t\treturn a, errs.E(op, errs.NotExist, \"Authentication Params not set to context\")\n\t}\n\treturn a, nil\n}\n"
  },
  {
    "path": "context_test.go",
    "content": "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/quicktest\"\n\t\"github.com/google/go-cmp/cmp\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\nfunc TestUserFromRequest(t *testing.T) {\n\tt.Run(\"typical\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\tr := httptest.NewRequest(http.MethodGet, \"/api/v1/movies\", nil)\n\n\t\twant := &User{\n\t\t\tID:                  uuid.New(),\n\t\t\tExternalID:          secure.NewID(),\n\t\t\tNamePrefix:          \"\",\n\t\t\tFirstName:           \"Otto\",\n\t\t\tMiddleName:          \"\",\n\t\t\tLastName:            \"Maddox\",\n\t\t\tFullName:            \"Otto Maddox\",\n\t\t\tNameSuffix:          \"\",\n\t\t\tNickname:            \"\",\n\t\t\tEmail:               \"otto.maddox@helpinghandacceptanceco.com\",\n\t\t\tCompanyName:         \"\",\n\t\t\tCompanyDepartment:   \"\",\n\t\t\tJobTitle:            \"\",\n\t\t\tBirthDate:           time.Time{},\n\t\t\tLanguagePreferences: nil,\n\t\t\tHostedDomain:        \"\",\n\t\t\tPictureURL:          \"\",\n\t\t\tProfileLink:         \"\",\n\t\t\tSource:              \"\",\n\t\t}\n\n\t\tctx := NewContextWithUser(context.Background(), want)\n\t\tr = r.WithContext(ctx)\n\n\t\tgot, err := UserFromRequest(r)\n\t\tc.Assert(err, qt.IsNil)\n\t\tc.Assert(got, qt.DeepEquals, want)\n\t})\n\tt.Run(\"no person added to Request context\", func(t *testing.T) {\n\t\tc := qt.New(t)\n\n\t\tr := httptest.NewRequest(http.MethodGet, \"/api/v1/ping\", nil)\n\n\t\tconst op1 errs.Op = \"diygoapi/UserFromContext\"\n\t\tconst op2 errs.Op = \"diygoapi/UserFromRequest\"\n\t\tctxErr := errs.E(op1, errs.NotExist, \"User not set properly to context\")\n\t\twantErr := errs.E(op2, ctxErr)\n\n\t\tu, err := UserFromRequest(r)\n\t\tc.Assert(err, qt.CmpEquals(cmp.Comparer(errs.Match)), wantErr)\n\t\tc.Assert(u, qt.IsNil)\n\t})\n}\n"
  },
  {
    "path": "db.go",
    "content": "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/pgx/v5/pgtype\"\n\t\"github.com/rs/zerolog\"\n)\n\n// Datastorer is an interface for working with the Database\ntype Datastorer interface {\n\t// Ping pings the DB pool.\n\tPing(ctx context.Context) error\n\t// BeginTx starts a pgx.Tx using the input context\n\tBeginTx(ctx context.Context) (pgx.Tx, error)\n\t// RollbackTx rolls back the input pgx.Tx\n\tRollbackTx(ctx context.Context, tx pgx.Tx, err error) error\n\t// CommitTx commits the Tx\n\tCommitTx(ctx context.Context, tx pgx.Tx) error\n}\n\n// DBTX interface mirrors the interface generated by https://github.com/kyleconroy/sqlc\n// to allow passing a Pool or a Tx\ntype DBTX interface {\n\tExec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)\n\tQuery(context.Context, string, ...interface{}) (pgx.Rows, error)\n\tQueryRow(context.Context, string, ...interface{}) pgx.Row\n}\n\n// PingServicer pings the database and responds whether it is up or down\ntype PingServicer interface {\n\tPing(ctx context.Context, lgr zerolog.Logger) PingResponse\n}\n\n// PingResponse is the response struct for the PingService\ntype PingResponse struct {\n\tDBUp bool `json:\"db_up\"`\n}\n\n// NewPgxInt4 returns a pgx/pgtype.Int4 with the input value\nfunc NewPgxInt4(i int32) pgtype.Int4 {\n\treturn pgtype.Int4{\n\t\tInt32: i,\n\t\tValid: true,\n\t}\n}\n\n// NewPgxInt8 returns a pgx/pgtype.Int8 with the input value\nfunc NewPgxInt8(i int64) pgtype.Int8 {\n\treturn pgtype.Int8{\n\t\tInt64: i,\n\t\tValid: true,\n\t}\n}\n\n// NewPgxText returns a pgx/pgtype.Text with the input value.\n// If the input string is empty, it returns an empty pgtype.Text\nfunc NewPgxText(s string) pgtype.Text {\n\tif len(s) == 0 {\n\t\treturn pgtype.Text{}\n\t}\n\treturn pgtype.Text{\n\t\tString: s,\n\t\tValid:  true,\n\t}\n}\n\n// NewPgxTimestampTZ returns a pgx/pgtype.Timestamptz with the input value\nfunc NewPgxTimestampTZ(t time.Time) pgtype.Timestamptz {\n\treturn pgtype.Timestamptz{\n\t\tTime:  t,\n\t\tValid: true,\n\t}\n}\n\n// NewPgxDate returns a pgx/pgtype.Date with the input value\nfunc NewPgxDate(t time.Time) pgtype.Date {\n\treturn pgtype.Date{\n\t\tTime:  t,\n\t\tValid: true,\n\t}\n}\n"
  },
  {
    "path": "diygoapi.go",
    "content": "// Package diygoapi comprises application or business domain data types and functions.\npackage diygoapi\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\n// LoggerServicer reads and updates the logger state\ntype LoggerServicer interface {\n\tRead() *LoggerResponse\n\tUpdate(r *LoggerRequest) (*LoggerResponse, error)\n}\n\n// GenesisServicer initializes the database with dependent data\ntype GenesisServicer interface {\n\t// Arche creates the initial seed data in the database.\n\tArche(ctx context.Context, r *GenesisRequest) (GenesisResponse, error)\n\t// ReadConfig reads the local config file generated as part of Seed (when run locally).\n\t// Is only a utility to help with local testing.\n\tReadConfig() (GenesisResponse, error)\n}\n\n// Audit represents the moment an App/User interacted with the system.\ntype Audit struct {\n\tApp    *App\n\tUser   *User\n\tMoment time.Time\n}\n\n// SimpleAudit captures the first time a record was written as well\n// as the last time the record was updated. The first time a record\n// is written Create and Update will be identical.\ntype SimpleAudit struct {\n\tCreate Audit `json:\"create\"`\n\tUpdate Audit `json:\"update\"`\n}\n\n// DeleteResponse is the general response struct for things\n// which have been deleted\ntype DeleteResponse struct {\n\tExternalID string `json:\"extl_id\"`\n\tDeleted    bool   `json:\"deleted\"`\n}\n\n// LoggerRequest is the request struct for the app logger\ntype LoggerRequest struct {\n\tGlobalLogLevel string `json:\"global_log_level\"`\n\tLogErrorStack  string `json:\"log_error_stack\"`\n}\n\n// LoggerResponse is the response struct for the current\n// state of the app logger\ntype LoggerResponse struct {\n\tLoggerMinimumLevel string `json:\"logger_minimum_level\"`\n\tGlobalLogLevel     string `json:\"global_log_level\"`\n\tLogErrorStack      bool   `json:\"log_error_stack\"`\n}\n\n// GenesisRequest is the request struct for the genesis service\ntype GenesisRequest struct {\n\tUser struct {\n\t\t// Provider: The Oauth2 provider.\n\t\tProvider string `json:\"provider\"`\n\n\t\t// Token: The Oauth2 token to be used to create the user.\n\t\tToken string `json:\"token\"`\n\t} `json:\"user\"`\n\n\tUserInitiatedOrg CreateOrgRequest `json:\"org\"`\n\n\t// PermissionRequests: The list of permissions to be created as part of Genesis\n\tCreatePermissionRequests []CreatePermissionRequest `json:\"permissions\"`\n\n\t// CreateRoleRequests: The list of Roles to be created as part of Genesis\n\tCreateRoleRequests []CreateRoleRequest `json:\"roles\"`\n}\n\n// GenesisResponse contains both the Genesis response and the Test response\ntype GenesisResponse struct {\n\tPrincipal     *OrgResponse `json:\"principal\"`\n\tTest          *OrgResponse `json:\"test\"`\n\tUserInitiated *OrgResponse `json:\"userInitiated,omitempty\"`\n}\n"
  },
  {
    "path": "go.mod",
    "content": "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 v0.7.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/jackc/pgconn v1.14.3\n\tgithub.com/jackc/pgx/v5 v5.8.0\n\tgithub.com/justinas/alice v1.2.0\n\tgithub.com/peterbourgon/ff/v3 v3.4.0\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/rs/zerolog v1.34.0\n\tgolang.org/x/oauth2 v0.35.0\n\tgolang.org/x/text v0.34.0\n\tgoogle.golang.org/api v0.267.0\n)\n\nrequire (\n\tcloud.google.com/go/auth v0.18.2 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect\n\tcloud.google.com/go/compute/metadata v0.9.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.17.0 // indirect\n\tgithub.com/jackc/chunkreader/v2 v2.0.1 // indirect\n\tgithub.com/jackc/pgio v1.0.0 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgproto3/v2 v2.3.3 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/kr/pretty v0.3.1 // indirect\n\tgithub.com/kr/text v0.2.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/rogpeppe/go-internal v1.14.1 // indirect\n\tgithub.com/rs/xid v1.6.0 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect\n\tgo.opentelemetry.io/otel v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.40.0 // indirect\n\tgolang.org/x/crypto v0.48.0 // indirect\n\tgolang.org/x/net v0.50.0 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n\tgolang.org/x/sys v0.41.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect\n\tgoogle.golang.org/grpc v1.79.1 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=\ncloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=\ncloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=\ncloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=\ncloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=\ncloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=\ngithub.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=\ngithub.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=\ngithub.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=\ngithub.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=\ngithub.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=\ngithub.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=\ngithub.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=\ngithub.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=\ngithub.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=\ngithub.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=\ngithub.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=\ngithub.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=\ngithub.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=\ngithub.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=\ngithub.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=\ngithub.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=\ngithub.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=\ngithub.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=\ngithub.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=\ngithub.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=\ngo.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=\ngo.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=\ngo.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=\ngo.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=\ngo.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=\ngo.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=\ngo.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=\ngo.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=\ngo.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=\ngo.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=\ngolang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=\ngolang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=\ngolang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=\ngolang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=\ngolang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=\ngolang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=\ngolang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=\ngoogle.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=\ngoogle.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=\ngoogle.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=\ngoogle.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=\ngoogle.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "movie.go",
    "content": "package diygoapi\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\n// MovieServicer is used to create, read, update and delete movies.\ntype MovieServicer interface {\n\tCreate(ctx context.Context, r *CreateMovieRequest, adt Audit) (*MovieResponse, error)\n\tUpdate(ctx context.Context, r *UpdateMovieRequest, adt Audit) (*MovieResponse, error)\n\tDelete(ctx context.Context, extlID string) (DeleteResponse, error)\n\tFindMovieByExternalID(ctx context.Context, extlID string) (*MovieResponse, error)\n\tFindAllMovies(ctx context.Context) ([]*MovieResponse, error)\n}\n\n// Movie holds details of a movie\ntype Movie struct {\n\tID         uuid.UUID\n\tExternalID secure.Identifier\n\tTitle      string\n\tRated      string\n\tReleased   time.Time\n\tRunTime    int64\n\tDirector   string\n\tWriter     string\n}\n\n// IsValid performs validation of the struct\nfunc (m *Movie) IsValid() error {\n\tconst op errs.Op = \"diygoapi/Movie.IsValid\"\n\n\tswitch {\n\tcase m.ExternalID.String() == \"\":\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"extlID\"), errs.MissingField(\"extlID\"))\n\tcase m.Title == \"\":\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"title\"), errs.MissingField(\"title\"))\n\tcase m.Rated == \"\":\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"rated\"), errs.MissingField(\"rated\"))\n\tcase m.Released.IsZero():\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"release_date\"), \"release_date must have a value\")\n\tcase m.RunTime <= 0:\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"run_time\"), \"run_time must be greater than zero\")\n\tcase m.Director == \"\":\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"director\"), errs.MissingField(\"director\"))\n\tcase m.Writer == \"\":\n\t\treturn errs.E(op, errs.Validation, errs.Parameter(\"writer\"), errs.MissingField(\"writer\"))\n\t}\n\n\treturn nil\n}\n\n// CreateMovieRequest is the request struct for Creating a Movie\ntype CreateMovieRequest struct {\n\tTitle    string `json:\"title\"`\n\tRated    string `json:\"rated\"`\n\tReleased string `json:\"release_date\"`\n\tRunTime  int64  `json:\"run_time\"`\n\tDirector string `json:\"director\"`\n\tWriter   string `json:\"writer\"`\n}\n\n// UpdateMovieRequest is the request struct for updating a Movie\ntype UpdateMovieRequest struct {\n\tExternalID string\n\tTitle      string `json:\"title\"`\n\tRated      string `json:\"rated\"`\n\tReleased   string `json:\"release_date\"`\n\tRunTime    int64  `json:\"run_time\"`\n\tDirector   string `json:\"director\"`\n\tWriter     string `json:\"writer\"`\n}\n\n// MovieResponse is the response struct for a Movie\ntype MovieResponse struct {\n\tExternalID          string `json:\"external_id\"`\n\tTitle               string `json:\"title\"`\n\tRated               string `json:\"rated\"`\n\tReleased            string `json:\"release_date\"`\n\tRunTime             int64  `json:\"run_time\"`\n\tDirector            string `json:\"director\"`\n\tWriter              string `json:\"writer\"`\n\tCreateAppExtlID     string `json:\"create_app_extl_id\"`\n\tCreateUserFirstName string `json:\"create_user_first_name\"`\n\tCreateUserLastName  string `json:\"create_user_last_name\"`\n\tCreateDateTime      string `json:\"create_date_time\"`\n\tUpdateAppExtlID     string `json:\"update_app_extl_id\"`\n\tUpdateUserFirstName string `json:\"update_user_first_name\"`\n\tUpdateUserLastName  string `json:\"update_user_last_name\"`\n\tUpdateDateTime      string `json:\"update_date_time\"`\n}\n"
  },
  {
    "path": "movie_test.go",
    "content": "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\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\nfunc TestMovie_IsValid(t *testing.T) {\n\tc := qt.New(t)\n\n\trd, _ := time.Parse(time.RFC3339, \"1985-08-16T00:00:00Z\")\n\n\tmovieFunc := func() *Movie {\n\t\treturn &Movie{\n\t\t\tID:         uuid.New(),\n\t\t\tExternalID: secure.NewID(),\n\t\t\tTitle:      \"The Return of the Living Dead\",\n\t\t\tRated:      \"R\",\n\t\t\tReleased:   rd,\n\t\t\tRunTime:    91,\n\t\t\tDirector:   \"Dan O'Bannon\",\n\t\t\tWriter:     \"Russell Streiner\",\n\t\t}\n\t}\n\n\tm1 := movieFunc()\n\tm2 := movieFunc()\n\tm2.ExternalID = nil\n\tm2a := movieFunc()\n\tm2a.ExternalID = secure.Identifier{}\n\tm3 := movieFunc()\n\tm3.Title = \"\"\n\tm4 := movieFunc()\n\tm4.Rated = \"\"\n\tm5 := movieFunc()\n\tm5.Released = time.Time{}\n\tm6 := movieFunc()\n\tm6.RunTime = 0\n\tm7 := movieFunc()\n\tm7.Director = \"\"\n\tm8 := movieFunc()\n\tm8.Writer = \"\"\n\n\ttests := []struct {\n\t\tname    string\n\t\tm       *Movie\n\t\twantErr error\n\t}{\n\t\t{\"typical no error\", m1, nil},\n\t\t{\"nil ExternalID\", m2, errs.E(errs.Validation, errs.Parameter(\"extlID\"), errs.MissingField(\"extlID\"))},\n\t\t{\"empty ExternalID\", m2a, errs.E(errs.Validation, errs.Parameter(\"extlID\"), errs.MissingField(\"extlID\"))},\n\t\t{\"empty Title\", m3, errs.E(errs.Validation, errs.Parameter(\"title\"), errs.MissingField(\"title\"))},\n\t\t{\"empty Rated\", m4, errs.E(errs.Validation, errs.Parameter(\"rated\"), errs.MissingField(\"rated\"))},\n\t\t{\"zero Released\", m5, errs.E(errs.Validation, errs.Parameter(\"release_date\"), \"release_date must have a value\")},\n\t\t{\"zero RunTime\", m6, errs.E(errs.Validation, errs.Parameter(\"run_time\"), \"run_time must be greater than zero\")},\n\t\t{\"empty Director\", m7, errs.E(errs.Validation, errs.Parameter(\"director\"), errs.MissingField(\"director\"))},\n\t\t{\"empty Writer\", m8, errs.E(errs.Validation, errs.Parameter(\"writer\"), errs.MissingField(\"writer\"))},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tisValidErr := tt.m.IsValid()\n\t\t\tif (isValidErr != nil) && (tt.wantErr == nil) {\n\t\t\t\tt.Errorf(\"IsValid() error = %v; nil expected\", isValidErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.Assert(isValidErr, qt.CmpEquals(cmp.Comparer(errs.Match)), tt.wantErr)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "org.go",
    "content": "package diygoapi\n\nimport (\n\t\"context\"\n\n\t\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\n// OrgServicer manages the retrieval and manipulation of an Org\ntype OrgServicer interface {\n\t// Create manages the creation of an Org (and optional app)\n\tCreate(ctx context.Context, r *CreateOrgRequest, adt Audit) (*OrgResponse, error)\n\tUpdate(ctx context.Context, r *UpdateOrgRequest, adt Audit) (*OrgResponse, error)\n\tDelete(ctx context.Context, extlID string) (DeleteResponse, error)\n\tFindAll(ctx context.Context) ([]*OrgResponse, error)\n\tFindByExternalID(ctx context.Context, extlID string) (*OrgResponse, error)\n}\n\n// OrgKind is a way of classifying an organization. Examples are Genesis, Test, Standard\ntype OrgKind struct {\n\t// ID: The unique identifier\n\tID uuid.UUID\n\t// External ID: The unique external identifier\n\tExternalID string\n\t// Description: A longer description of the organization kind\n\tDescription string\n}\n\n// Validate determines whether the Person has proper data to be considered valid\nfunc (o OrgKind) Validate() error {\n\tconst op errs.Op = \"diygoapi/OrgKind.Validate\"\n\n\tswitch {\n\tcase o.ID == uuid.Nil:\n\t\treturn errs.E(op, errs.Validation, \"OrgKind ID cannot be nil\")\n\tcase o.ExternalID == \"\":\n\t\treturn errs.E(op, errs.Validation, \"OrgKind ExternalID cannot be empty\")\n\tcase o.Description == \"\":\n\t\treturn errs.E(op, errs.Validation, \"OrgKind Description cannot be empty\")\n\t}\n\n\treturn nil\n}\n\n// Org represents an Organization (company, institution or any other\n// organized body of people with a particular purpose)\ntype Org struct {\n\t// ID: The unique identifier\n\tID uuid.UUID\n\t// External ID: The unique external identifier\n\tExternalID secure.Identifier\n\t// Name: The organization name\n\tName string\n\t// Description: A longer description of the organization\n\tDescription string\n\t// Kind: a way of classifying organizations\n\tKind *OrgKind\n}\n\n// Validate determines whether the Org has proper data to be considered valid\nfunc (o Org) Validate() (err error) {\n\tconst op errs.Op = \"diygoapi/Org.Validate\"\n\n\tswitch {\n\tcase o.ID == uuid.Nil:\n\t\treturn errs.E(op, errs.Validation, \"Org ID cannot be nil\")\n\tcase o.ExternalID.String() == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Org ExternalID cannot be empty\")\n\tcase o.Name == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Org Name cannot be empty\")\n\tcase o.Description == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Org Description cannot be empty\")\n\t}\n\n\tif err = o.Kind.Validate(); err != nil {\n\t\treturn errs.E(op, err)\n\t}\n\n\treturn nil\n}\n\n// CreateOrgRequest is the request struct for Creating an Org\ntype CreateOrgRequest struct {\n\tName             string            `json:\"name\"`\n\tDescription      string            `json:\"description\"`\n\tKind             string            `json:\"kind\"`\n\tCreateAppRequest *CreateAppRequest `json:\"app\"`\n}\n\n// Validate determines whether the CreateOrgRequest has proper data to be considered valid\nfunc (r CreateOrgRequest) Validate() error {\n\tconst op errs.Op = \"diygoapi/CreateOrgRequest.Validate\"\n\n\tswitch {\n\tcase r.Name == \"\":\n\t\treturn errs.E(op, errs.Validation, \"org name is required\")\n\tcase r.Description == \"\":\n\t\treturn errs.E(op, errs.Validation, \"org description is required\")\n\tcase r.Kind == \"\":\n\t\treturn errs.E(op, errs.Validation, \"org kind is required\")\n\t}\n\treturn nil\n}\n\n// UpdateOrgRequest is the request struct for Updating an Org\ntype UpdateOrgRequest struct {\n\tExternalID  string\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// OrgResponse is the response struct for an Org.\n// It contains only one app (even though an org can have many apps).\n// This app is only present in the response when creating an org and\n// accompanying app. I may change this later to be different response\n// structs for different purposes, but for now, this works.\ntype OrgResponse struct {\n\tExternalID          string       `json:\"external_id\"`\n\tName                string       `json:\"name\"`\n\tKindExternalID      string       `json:\"kind_description\"`\n\tDescription         string       `json:\"description\"`\n\tCreateAppExtlID     string       `json:\"create_app_extl_id\"`\n\tCreateUserFirstName string       `json:\"create_user_first_name\"`\n\tCreateUserLastName  string       `json:\"create_user_last_name\"`\n\tCreateDateTime      string       `json:\"create_date_time\"`\n\tUpdateAppExtlID     string       `json:\"update_app_extl_id\"`\n\tUpdateUserFirstName string       `json:\"update_user_first_name\"`\n\tUpdateUserLastName  string       `json:\"update_user_last_name\"`\n\tUpdateDateTime      string       `json:\"update_date_time\"`\n\tApp                 *AppResponse `json:\"app,omitempty\"`\n}\n"
  },
  {
    "path": "user.go",
    "content": "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\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\n// RegisterUserServicer registers a new user\ntype RegisterUserServicer interface {\n\tSelfRegister(ctx context.Context, adt Audit) error\n}\n\n// Person - from Wikipedia: \"A person (plural people or persons) is a being that\n// has certain capacities or attributes such as reason, morality, consciousness or\n// self-consciousness, and being a part of a culturally established form of social\n// relations such as kinship, ownership of property, or legal responsibility.\n//\n// The defining features of personhood and, consequently, what makes a person count\n// as a person, differ widely among cultures and contexts.\"\n//\n// A Person can have multiple Users.\ntype Person struct {\n\t// ID: The unique identifier of the Person.\n\tID uuid.UUID\n\n\t// ExternalID: unique external identifier of the Person\n\tExternalID secure.Identifier\n\n\t// Users: All the users that are linked to the Person\n\t// (e.g. a GitHub user, a Google user, etc.).\n\tUsers []*User\n}\n\n// Validate determines whether the Person has proper data to be considered valid\nfunc (p Person) Validate() (err error) {\n\tconst op errs.Op = \"diygoapi/Person.Validate\"\n\n\tswitch {\n\tcase p.ID == uuid.Nil:\n\t\treturn errs.E(op, errs.Validation, \"Person ID cannot be nil\")\n\tcase p.ExternalID.String() == \"\":\n\t\treturn errs.E(op, errs.Validation, \"Person ExternalID cannot be empty\")\n\t}\n\n\treturn nil\n}\n\n// UserResponse - from Wikipedia: \"A user is a person who utilizes a computer or network service.\" In the context of this\n// project, given that we allow Persons to authenticate with multiple providers, a User is akin to a persona\n// (Wikipedia - \"The word persona derives from Latin, where it originally referred to a theatrical mask. On the\n// social web, users develop virtual personas as online identities.\") and as such, a Person can have one or many\n// Users (for instance, I can have a GitHub user and a Google user, but I am just one Person).\n//\n// As a general, practical matter, most operations are considered at the User level. For instance, roles are\n// assigned at the user level instead of the Person level, which allows for more fine-grained access control.\ntype UserResponse struct {\n\t// ID: The unique identifier for the Person's profile\n\tID uuid.UUID\n\n\t// ExternalID: unique external identifier of the User\n\tExternalID secure.Identifier `json:\"external_id\"`\n\n\t// NamePrefix: The name prefix for the Profile (e.g. Mx., Ms., Mr., etc.)\n\tNamePrefix string `json:\"name_prefix\"`\n\n\t// FirstName: The person's first name.\n\tFirstName string `json:\"first_name\"`\n\n\t// MiddleName: The person's middle name.\n\tMiddleName string `json:\"middle_name\"`\n\n\t// LastName: The person's last name.\n\tLastName string `json:\"last_name\"`\n\n\t// FullName: The person's full name.\n\tFullName string `json:\"full_name\"`\n\n\t// NameSuffix: The name suffix for the person's name (e.g. \"PhD\", \"CCNA\", \"OBE\").\n\t// Other examples include generational designations like \"Sr.\" and \"Jr.\" and \"I\", \"II\", \"III\", etc.\n\tNameSuffix string `json:\"name_suffix\"`\n\n\t// Nickname: The person's nickname\n\tNickname string `json:\"nickname\"`\n\n\t// Email: The primary email for the User\n\tEmail string `json:\"email\"`\n\n\t// CompanyName: The Company Name that the person works at\n\tCompanyName string `json:\"company_name\"`\n\n\t// CompanyDepartment: is the department at the company that the person works at\n\tCompanyDepartment string `json:\"company_department\"`\n\n\t// JobTitle: The person's Job Title\n\tJobTitle string `json:\"job_title\"`\n\n\t// BirthDate: The full birthdate of a person (e.g. Dec 18, 1953)\n\tBirthDate time.Time `json:\"birth_date\"`\n\n\t// LanguagePreferences is the user's language tag preferences.\n\tLanguagePreferences []language.Tag `json:\"language_preferences\"`\n\n\t// HostedDomain: The hosted domain e.g. example.com.\n\tHostedDomain string `json:\"hosted_domain\"`\n\n\t// PictureURL: URL of the person's picture image for the profile.\n\tPictureURL string `json:\"picture_url\"`\n\n\t// ProfileLink: URL of the profile page.\n\tProfileLink string `json:\"profile_link\"`\n\n\t// Source: The origin of the User (e.g. Google Oauth2, Apple Oauth2, etc.)\n\tSource string `json:\"source\"`\n}\n\n// User - from Wikipedia: \"A user is a person who utilizes a computer or network service.\" In the context of this\n// project, given that we allow Persons to authenticate with multiple providers, a User is akin to a persona\n// (Wikipedia - \"The word persona derives from Latin, where it originally referred to a theatrical mask. On the\n// social web, users develop virtual personas as online identities.\") and as such, a Person can have one or many\n// Users (for instance, I can have a GitHub user and a Google user, but I am just one Person).\n//\n// As a general, practical matter, most operations are considered at the User level. For instance, roles are\n// assigned at the user level instead of the Person level, which allows for more fine-grained access control.\ntype User struct {\n\t// ID: The unique identifier for the Person's profile\n\tID uuid.UUID\n\n\t// ExternalID: unique external identifier of the User\n\tExternalID secure.Identifier\n\n\t// NamePrefix: The name prefix for the Profile (e.g. Mx., Ms., Mr., etc.)\n\tNamePrefix string\n\n\t// FirstName: The person's first name.\n\tFirstName string\n\n\t// MiddleName: The person's middle name.\n\tMiddleName string\n\n\t// LastName: The person's last name.\n\tLastName string\n\n\t// FullName: The person's full name.\n\tFullName string\n\n\t// NameSuffix: The name suffix for the person's name (e.g. \"PhD\", \"CCNA\", \"OBE\").\n\t// Other examples include generational designations like \"Sr.\" and \"Jr.\" and \"I\", \"II\", \"III\", etc.\n\tNameSuffix string\n\n\t// Nickname: The person's nickname\n\tNickname string\n\n\t// Gender: The user's gender. TODO - setup Gender properly. not binary.\n\tGender string\n\n\t// Email: The primary email for the User\n\tEmail string\n\n\t// CompanyName: The Company Name that the person works at\n\tCompanyName string\n\n\t// CompanyDepartment: is the department at the company that the person works at\n\tCompanyDepartment string\n\n\t// JobTitle: The person's Job Title\n\tJobTitle string\n\n\t// BirthDate: The full birthdate of a person (e.g. Dec 18, 1953)\n\tBirthDate time.Time\n\n\t// LanguagePreferences is the user's language tag preferences.\n\tLanguagePreferences []language.Tag\n\n\t// HostedDomain: The hosted domain e.g. example.com.\n\tHostedDomain string\n\n\t// PictureURL: URL of the person's picture image for the profile.\n\tPictureURL string\n\n\t// ProfileLink: URL of the profile page.\n\tProfileLink string\n\n\t// Source: The origin of the User (e.g. Google Oauth2, Apple Oauth2, etc.)\n\tSource string\n}\n\n// Validate determines whether the Person has proper data to be considered valid\nfunc (u User) Validate() error {\n\tconst op errs.Op = \"diygoapi/User.Validate\"\n\n\tswitch {\n\tcase u.ID == uuid.Nil:\n\t\treturn errs.E(op, errs.Validation, \"User ID cannot be nil\")\n\tcase u.ExternalID.String() == \"\":\n\t\treturn errs.E(op, errs.Validation, \"User ExternalID cannot be empty\")\n\tcase u.LastName == \"\":\n\t\treturn errs.E(op, errs.Validation, \"User LastName cannot be empty\")\n\tcase u.FirstName == \"\":\n\t\treturn errs.E(op, errs.Validation, \"User FirstName cannot be empty\")\n\t}\n\n\treturn nil\n}\n\n// NewUserFromProviderInfo creates a new User struct to be used in db user creation\nfunc NewUserFromProviderInfo(pi *ProviderInfo, lm language.Matcher) *User {\n\tvar langPrefs []language.Tag\n\t// Match the user's locale to the supported languages\n\tlangPref, _, _ := lm.Match(language.Make(pi.UserInfo.Locale))\n\t// Append the matched language to the language preferences\n\tlangPrefs = append(langPrefs, langPref)\n\n\t// create User from ProviderInfo\n\tu := &User{\n\t\tID:                  uuid.New(),\n\t\tExternalID:          secure.NewID(),\n\t\tNamePrefix:          pi.UserInfo.NamePrefix,\n\t\tFirstName:           pi.UserInfo.FirstName,\n\t\tMiddleName:          pi.UserInfo.MiddleName,\n\t\tLastName:            pi.UserInfo.LastName,\n\t\tFullName:            pi.UserInfo.FullName,\n\t\tNameSuffix:          pi.UserInfo.NameSuffix,\n\t\tNickname:            pi.UserInfo.Nickname,\n\t\tGender:              pi.UserInfo.Gender,\n\t\tEmail:               pi.UserInfo.Email,\n\t\tBirthDate:           pi.UserInfo.BirthDate,\n\t\tLanguagePreferences: langPrefs,\n\t\tHostedDomain:        pi.UserInfo.HostedDomain,\n\t\tPictureURL:          pi.UserInfo.Picture,\n\t\tProfileLink:         pi.UserInfo.ProfileLink,\n\t\tSource:              pi.Provider.String(),\n\t}\n\n\treturn u\n}\n"
  },
  {
    "path": "user_test.go",
    "content": "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\"github.com/gilcrest/diygoapi/errs\"\n\t\"github.com/gilcrest/diygoapi/secure\"\n\t\"github.com/gilcrest/diygoapi/uuid\"\n)\n\nfunc TestUser_Validate(t *testing.T) {\n\tc := qt.New(t)\n\n\ttype fields struct {\n\t\tEmail        string\n\t\tLastName     string\n\t\tFirstName    string\n\t\tFullName     string\n\t\tHostedDomain string\n\t\tPictureURL   string\n\t\tProfileLink  string\n\t}\n\n\totto := fields{\n\t\tEmail:        \"otto.maddox@helpinghandacceptanceco.com\",\n\t\tLastName:     \"Maddox\",\n\t\tFirstName:    \"Otto\",\n\t\tFullName:     \"Otto Maddox\",\n\t\tHostedDomain: \"\",\n\t\tPictureURL:   \"\",\n\t\tProfileLink:  \"\",\n\t}\n\n\tnoLastName := fields{\n\t\tEmail:        \"otto.maddox@helpinghandacceptanceco.com\",\n\t\tLastName:     \"\",\n\t\tFirstName:    \"Otto\",\n\t\tFullName:     \"Otto Maddox\",\n\t\tHostedDomain: \"\",\n\t\tPictureURL:   \"\",\n\t\tProfileLink:  \"\",\n\t}\n\n\tnoFirstName := fields{\n\t\tEmail:        \"otto.maddox@helpinghandacceptanceco.com\",\n\t\tLastName:     \"Maddox\",\n\t\tFirstName:    \"\",\n\t\tFullName:     \"Otto Maddox\",\n\t\tHostedDomain: \"\",\n\t\tPictureURL:   \"\",\n\t\tProfileLink:  \"\",\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tfields  fields\n\t\twantErr error\n\t}{\n\t\t{\"typical\", otto, nil},\n\t\t{\"no last name\", noLastName, errs.E(errs.Validation, \"User LastName cannot be empty\")},\n\t\t{\"no first name\", noFirstName, errs.E(errs.Validation, \"User FirstName cannot be empty\")},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tuser := User{\n\t\t\t\tID:                  uuid.New(),\n\t\t\t\tExternalID:          secure.NewID(),\n\t\t\t\tNamePrefix:          \"\",\n\t\t\t\tFirstName:           tt.fields.FirstName,\n\t\t\t\tMiddleName:          \"\",\n\t\t\t\tLastName:            tt.fields.LastName,\n\t\t\t\tFullName:            tt.fields.FullName,\n\t\t\t\tNameSuffix:          \"\",\n\t\t\t\tNickname:            \"\",\n\t\t\t\tEmail:               tt.fields.Email,\n\t\t\t\tCompanyName:         \"\",\n\t\t\t\tCompanyDepartment:   \"\",\n\t\t\t\tJobTitle:            \"\",\n\t\t\t\tBirthDate:           time.Date(2008, 1, 17, 0, 0, 0, 0, time.UTC),\n\t\t\t\tLanguagePreferences: nil,\n\t\t\t\tHostedDomain:        tt.fields.HostedDomain,\n\t\t\t\tPictureURL:          tt.fields.PictureURL,\n\t\t\t\tProfileLink:         tt.fields.ProfileLink,\n\t\t\t\tSource:              \"\",\n\t\t\t}\n\t\t\terr := user.Validate()\n\t\t\tc.Assert(err, qt.CmpEquals(cmp.Comparer(errs.Match)), tt.wantErr)\n\t\t})\n\t}\n}\n"
  }
]