From 7e940c83a7cb2209cb03d398232d7e38404d0f61 Mon Sep 17 00:00:00 2001 From: Zane Walker Date: Fri, 3 Jul 2026 19:41:31 +0530 Subject: [PATCH] (Feat): Initial Commit --- .changes-go-starter/24.11.06.md | 758 +++++++ .changes-go-starter/25.01.0.md | 31 + .changes-go-starter/25.01.1.md | 3 + .changes-go-starter/25.01.2.md | 6 + .changes-go-starter/25.01.3.md | 4 + .changes-go-starter/25.02.0.md | 29 + .changes-go-starter/25.02.1.md | 7 + .changes-go-starter/25.04.0.md | 35 + .changes-go-starter/25.04.1.md | 3 + .changes-go-starter/25.10.0.md | 34 + .changes-go-starter/header.tpl.md | 9 + .changes-go-starter/prerelease/.gitkeep | 0 .changes-go-starter/unreleased/.gitkeep | 0 .changie-go-starter.yaml | 20 + .devcontainer/devcontainer.json | 114 + .dockerignore | 7 + .drone.yml | 453 ++++ .env.local.sample | 30 + .github/README.md | 1 + .github/dependabot.yml | 22 + .github/workflows/build-test.yml | 102 + .github/workflows/codeql-analysis.yml | 75 + .github/workflows/docker.env | 22 + .gitignore | 45 + .golangci.yml | 141 ++ .spectral.yml | 87 + .trivyignore | 0 .vscode/extensions.json | 9 + .vscode/launch.json | 39 + .vscode/tasks.json | 16 + Dockerfile | 276 +++ Makefile | 494 ++++ SECURITY.md | 11 + api/README.md | 22 + api/config/go-swagger-config.yml | 16 + api/config/main.yml | 37 + api/config/swagger-ui-local-translator.js | 122 + api/definitions/auth.yml | 195 ++ api/definitions/common.yml | 47 + api/definitions/errors.yml | 79 + api/definitions/push.yml | 28 + api/paths/auth.yml | 289 +++ api/paths/common.yml | 71 + api/paths/push.yml | 33 + api/paths/well_known.yml | 27 + api/swagger.yml | 738 ++++++ api/templates/server/builder.README | 5 + api/templates/server/builder.gotmpl | 49 + api/templates/server/parameter.README | 3 + api/templates/server/parameter.gotmpl | 759 +++++++ assets/README.md | 13 + assets/mnt/.gitkeep | 0 cmd/README.md | 11 + cmd/db/db.go | 13 + cmd/db/migrate.go | 97 + cmd/db/seed.go | 77 + cmd/env/env.go | 38 + cmd/probe/liveness.go | 89 + cmd/probe/probe.go | 17 + cmd/probe/readiness.go | 89 + cmd/root.go | 44 + cmd/server/server.go | 105 + dbconfig.yml | 15 + docker-compose.yml | 168 ++ docker-helper.sh | 22 + docs/README.md | 11 + docs/schemacrawler/.gitignore | 1 + docs/schemacrawler/README.md | 31 + .../schemacrawler.config.properties | 181 ++ docs/server-initialization.md | 106 + go.mod | 150 ++ go.not | 7 + go.sum | 489 ++++ internal/README.md | 55 + .../api/handlers/auth/delete_user_account.go | 41 + .../handlers/auth/delete_user_account_test.go | 136 ++ .../handlers/auth/get_complete_register.go | 39 + .../auth/get_complete_register_test.go | 27 + internal/api/handlers/auth/get_userinfo.go | 33 + .../api/handlers/auth/get_userinfo_test.go | 69 + .../api/handlers/auth/post_change_password.go | 42 + .../auth/post_change_password_test.go | 187 ++ .../handlers/auth/post_complete_register.go | 40 + .../auth/post_complete_register_test.go | 70 + .../api/handlers/auth/post_forgot_password.go | 57 + .../auth/post_forgot_password_complete.go | 39 + .../post_forgot_password_complete_test.go | 280 +++ .../auth/post_forgot_password_test.go | 256 +++ internal/api/handlers/auth/post_login.go | 39 + internal/api/handlers/auth/post_login_test.go | 179 ++ internal/api/handlers/auth/post_logout.go | 45 + .../api/handlers/auth/post_logout_test.go | 129 ++ internal/api/handlers/auth/post_refresh.go | 37 + .../api/handlers/auth/post_refresh_test.go | 108 + internal/api/handlers/auth/post_register.go | 72 + .../api/handlers/auth/post_register_test.go | 334 +++ internal/api/handlers/common/get_healthy.go | 55 + .../api/handlers/common/get_healthy_test.go | 110 + internal/api/handlers/common/get_ready.go | 40 + .../api/handlers/common/get_ready_test.go | 41 + internal/api/handlers/common/get_swagger.go | 20 + .../api/handlers/common/get_swagger_test.go | 32 + internal/api/handlers/common/get_version.go | 21 + .../api/handlers/common/get_version_test.go | 33 + internal/api/handlers/common/probes.go | 226 ++ .../handlers/common/probes_internal_test.go | 70 + internal/api/handlers/constants/constants.go | 5 + internal/api/handlers/handlers.go | 35 + .../handlers/push/put_update_push_token.go | 44 + .../push/put_update_push_token_test.go | 169 ++ .../wellknown/get_android_assetlinks.go | 23 + .../wellknown/get_android_assetlinks_test.go | 30 + .../get_apple_app_site_association.go | 21 + .../get_apple_app_site_association_test.go | 47 + internal/api/httperrors/auth.go | 16 + internal/api/httperrors/common.go | 11 + internal/api/httperrors/error.go | 147 ++ internal/api/httperrors/error_test.go | 80 + internal/api/httperrors/push.go | 12 + internal/api/middleware/auth.go | 390 ++++ internal/api/middleware/cache_control.go | 55 + internal/api/middleware/logger.go | 334 +++ internal/api/middleware/logger_test.go | 57 + internal/api/middleware/no_cache.go | 91 + internal/api/middleware/noop.go | 11 + internal/api/middleware/recover.go | 15 + internal/api/middleware/recover_test.go | 41 + internal/api/providers.go | 81 + internal/api/router/echo_logger.go | 13 + internal/api/router/echo_renderer.go | 27 + internal/api/router/handlers.go | 150 ++ internal/api/router/router.go | 252 +++ internal/api/router/router_test.go | 128 ++ internal/api/router/templates/templates.go | 11 + internal/api/server.go | 154 ++ internal/api/wire.go | 52 + internal/api/wire_gen.go | 94 + internal/auth/constants.go | 5 + internal/auth/context.go | 82 + internal/auth/errors.go | 7 + internal/auth/scopes.go | 11 + internal/auth/service.go | 620 +++++ internal/auth/types.go | 14 + internal/config/build_args.go | 18 + internal/config/db_config.go | 57 + internal/config/db_config_test.go | 68 + internal/config/dot_env.go | 71 + internal/config/dot_env_test.go | 63 + internal/config/mailer_config.go | 19 + internal/config/server_config.go | 258 +++ internal/config/server_config_test.go | 17 + internal/config/service_config.go | 6 + internal/config/testdata/.env.local.malformed | 1 + internal/config/testdata/.env.local.sample | 2 + internal/config/testdata/.env1.local | 3 + internal/config/testdata/.env2.local | 2 + internal/data/dto/push.go | 10 + internal/data/dto/users.go | 157 ++ internal/data/fixtures/fixtures.go | 36 + internal/data/fixtures/fixtures_test.go | 18 + internal/data/local/service.go | 22 + internal/data/local/service_push.go | 75 + internal/data/mapper/users.go | 32 + internal/i18n/i18n.go | 211 ++ internal/i18n/i18n_test.go | 589 +++++ internal/i18n/testdata/README.md | 3 + internal/i18n/testdata/i18n-empty/.gitkeep | 0 internal/i18n/testdata/i18n-invalid/en.toml | 2 + internal/i18n/testdata/i18n-plural/de.toml | 13 + internal/i18n/testdata/i18n-plural/en.toml | 12 + internal/i18n/testdata/i18n-reserved/de.toml | 34 + internal/i18n/testdata/i18n-reserved/en.toml | 34 + .../i18n/testdata/i18n-specialized/de-at.toml | 2 + .../i18n/testdata/i18n-specialized/de-de.toml | 2 + .../i18n/testdata/i18n-specialized/en-uk.toml | 2 + .../i18n/testdata/i18n-specialized/en-us.toml | 2 + .../i18n/testdata/i18n-undetermined/xx.toml | 2 + internal/i18n/testdata/i18n/de.toml | 4 + internal/i18n/testdata/i18n/en.toml | 4 + internal/mailer/mailer.go | 162 ++ internal/mailer/mailer_test.go | 38 + internal/mailer/transport/mock.go | 95 + internal/mailer/transport/smtp.go | 56 + internal/mailer/transport/smtp_config.go | 81 + internal/mailer/transport/smtp_login_auth.go | 50 + internal/mailer/transport/transport.go | 7 + internal/metrics/service.go | 46 + internal/metrics/users/collector.go | 29 + internal/metrics/users/metrics.go | 27 + internal/models/access_tokens.go | 962 ++++++++ internal/models/access_tokens_test.go | 701 ++++++ internal/models/app_user_profiles.go | 928 ++++++++ internal/models/app_user_profiles_test.go | 697 ++++++ internal/models/boil_main_test.go | 119 + internal/models/boil_queries.go | 38 + internal/models/boil_queries_test.go | 51 + internal/models/boil_relationship_test.go | 76 + internal/models/boil_suites_test.go | 179 ++ internal/models/boil_table_names.go | 22 + internal/models/boil_types.go | 65 + internal/models/boil_view_names.go | 7 + internal/models/confirmation_tokens.go | 910 ++++++++ internal/models/confirmation_tokens_test.go | 701 ++++++ internal/models/password_reset_tokens.go | 910 ++++++++ internal/models/password_reset_tokens_test.go | 701 ++++++ internal/models/psql_main_test.go | 231 ++ internal/models/psql_suites_test.go | 22 + internal/models/psql_upsert.go | 99 + internal/models/push_tokens.go | 917 ++++++++ internal/models/push_tokens_test.go | 701 ++++++ internal/models/refresh_tokens.go | 903 ++++++++ internal/models/refresh_tokens_test.go | 701 ++++++ internal/models/users.go | 1986 +++++++++++++++++ internal/models/users_test.go | 1470 ++++++++++++ internal/persistence/postgres.go | 40 + internal/persistence/postgres_test.go | 63 + internal/push/provider/fcm_provider.go | 76 + internal/push/provider/helper.go | 13 + internal/push/provider/mock_provider.go | 51 + internal/push/service.go | 107 + internal/push/service_test.go | 111 + internal/test/README.md | 40 + internal/test/fixtures/fixtures.go | 172 ++ internal/test/fixtures/fixtures_test.go | 97 + internal/test/helper_compare.go | 88 + internal/test/helper_compare_test.go | 97 + internal/test/helper_dot_env.go | 41 + internal/test/helper_dot_env_test.go | 25 + internal/test/helper_files.go | 62 + internal/test/helper_files_test.go | 31 + internal/test/helper_mappers.go | 64 + internal/test/helper_mappers_test.go | 81 + internal/test/helper_request.go | 139 ++ internal/test/helper_snapshot.go | 354 +++ internal/test/helper_snapshot_test.go | 358 +++ internal/test/mocks/TestingT.go | 154 ++ internal/test/test_clock.go | 27 + internal/test/test_clock_test.go | 26 + internal/test/test_database.go | 382 ++++ internal/test/test_database_test.go | 229 ++ internal/test/test_mailer.go | 66 + internal/test/test_mailer_test.go | 67 + internal/test/test_pusher.go | 28 + internal/test/test_server.go | 93 + internal/test/test_server_test.go | 151 ++ internal/test/testdata/.env.test.local | 2 + .../testdata/TestSnapshotWithLocation.golden | 6 + internal/test/testing_mock.go | 31 + .../delete_user_account_route_parameters.go | 87 + .../get_complete_register_route_parameters.go | 120 + .../auth/get_user_info_route_parameters.go | 55 + .../post_change_password_route_parameters.go | 87 + ...post_complete_register_route_parameters.go | 108 + ...rgot_password_complete_route_parameters.go | 87 + .../post_forgot_password_route_parameters.go | 87 + .../types/auth/post_login_route_parameters.go | 87 + .../auth/post_logout_route_parameters.go | 87 + .../auth/post_refresh_route_parameters.go | 87 + .../auth/post_register_route_parameters.go | 87 + .../common/get_healthy_route_parameters.go | 55 + .../common/get_ready_route_parameters.go | 55 + .../common/get_swagger_route_parameters.go | 55 + .../common/get_version_route_parameters.go | 55 + internal/types/delete_user_account_payload.go | 82 + internal/types/get_user_info_response.go | 162 ++ .../types/http_validation_error_detail.go | 105 + internal/types/order_dir.go | 78 + .../types/post_change_password_payload.go | 110 + .../post_forgot_password_complete_payload.go | 105 + .../types/post_forgot_password_payload.go | 87 + internal/types/post_login_payload.go | 115 + internal/types/post_login_response.go | 136 ++ internal/types/post_logout_payload.go | 75 + internal/types/post_refresh_payload.go | 77 + internal/types/post_register_payload.go | 115 + internal/types/public_http_error.go | 161 ++ internal/types/public_http_error_type.go | 111 + .../types/public_http_validation_error.go | 175 ++ .../put_update_push_token_route_parameters.go | 87 + .../types/put_update_push_token_payload.go | 121 + internal/types/register_response.go | 72 + internal/types/spec_handlers.go | 57 + ...id_digital_asset_links_route_parameters.go | 55 + ...e_app_site_association_route_parameters.go | 55 + internal/util/bool.go | 11 + internal/util/bool_test.go | 16 + internal/util/cache_control.go | 69 + internal/util/cache_control_test.go | 77 + internal/util/command/command.go | 76 + internal/util/command/command_test.go | 34 + internal/util/context.go | 77 + internal/util/context_test.go | 86 + internal/util/currency.go | 60 + internal/util/currency_test.go | 66 + internal/util/db/db.go | 109 + internal/util/db/db_test.go | 190 ++ internal/util/db/example_test.go | 69 + internal/util/db/ilike.go | 47 + internal/util/db/ilike_test.go | 46 + internal/util/db/join.go | 63 + internal/util/db/join_test.go | 104 + internal/util/db/json.go | 126 ++ internal/util/db/json_test.go | 205 ++ internal/util/db/or.go | 22 + internal/util/db/or_test.go | 67 + internal/util/db/order_by.go | 32 + internal/util/db/order_by_test.go | 68 + internal/util/db/query_mods.go | 17 + internal/util/db/ts_vector.go | 28 + internal/util/db/ts_vector_test.go | 41 + internal/util/db/where_in.go | 25 + internal/util/db/where_in_test.go | 39 + internal/util/env.go | 223 ++ internal/util/env_test.go | 278 +++ internal/util/fs.go | 39 + internal/util/fs_test.go | 28 + internal/util/get_project_root_dir.go | 36 + internal/util/get_project_root_dir_scripts.go | 21 + internal/util/hashing/argon2.go | 114 + internal/util/hashing/argon2_params.go | 39 + internal/util/hashing/argon2_test.go | 106 + internal/util/hashing/util.go | 17 + internal/util/http.go | 374 ++++ internal/util/http_test.go | 233 ++ internal/util/int.go | 32 + internal/util/int_test.go | 27 + internal/util/lang.go | 23 + internal/util/lang_test.go | 34 + internal/util/log.go | 44 + internal/util/log_test.go | 20 + internal/util/map.go | 12 + internal/util/map_test.go | 41 + internal/util/mime/mime.go | 33 + internal/util/mime/mime_test.go | 30 + internal/util/oauth2/pkce.go | 35 + internal/util/oauth2/pkce_test.go | 16 + internal/util/path.go | 40 + internal/util/path_test.go | 42 + internal/util/slice.go | 39 + internal/util/slice_test.go | 31 + internal/util/string.go | 185 ++ internal/util/string_test.go | 82 + internal/util/struct.go | 107 + internal/util/struct_test.go | 321 +++ internal/util/time.go | 113 + internal/util/time_test.go | 174 ++ internal/util/url/url.go | 55 + internal/util/wait_group.go | 30 + internal/util/wait_group_test.go | 48 + main.go | 7 + .../20200428064736-install-extension-uuid.sql | 6 + .../20200428064802-create-user-scope-enum.sql | 9 + migrations/20200428072232-create-users.sql | 19 + ...0200428072359-create-app-user-profiles.sql | 15 + .../20200428072541-create-access-tokens.sql | 18 + .../20200428072639-create-refresh-tokens.sql | 17 + ...428072651-create-password-reset-tokens.sql | 18 + .../20200529112509-create-push-token.sql | 29 + .../20200811180414-create-health-sequence.sql | 6 + ...74511-create-table-confirmation-tokens.sql | 24 + migrations/README.md | 6 + rksh | 59 + scripts/README.md | 42 + scripts/cmd/handlers.go | 29 + scripts/cmd/handlers_check.go | 37 + scripts/cmd/handlers_gen.go | 38 + scripts/cmd/modulename.go | 34 + scripts/cmd/root.go | 34 + scripts/cmd/scaffold.go | 86 + scripts/internal/handlers/check.go | 120 + scripts/internal/handlers/gen.go | 183 ++ scripts/internal/scaffold/scaffold.go | 367 +++ scripts/internal/scaffold/scaffold_test.go | 86 + scripts/internal/scaffold/templates.go | 366 +++ .../scaffold/testdata/test_resource.txt | 758 +++++++ scripts/internal/util/get_module_name.go | 28 + scripts/internal/util/get_project_root_dir.go | 14 + scripts/main.go | 9 + scripts/sql/default_zero_values.sql | 89 + scripts/sql/fk_missing_index.sql | 57 + scripts/sql/info.sql | 15 + sqlboiler.toml | 10 + test/testdata/android-assetlinks.json | 14 + test/testdata/apple-app-site-association.json | 18 + test/testdata/example.jpg | Bin 0 -> 6749 bytes test/testdata/plain.sql | 45 + .../snapshots/TestGetAndroidWellKnown.golden | 14 + .../snapshots/TestGetAppleWellKnown.golden | 18 + .../snapshots/TestGetCompleteRegister.golden | 30 + .../testdata/snapshots/TestGetUserInfo.golden | 8 + test/testdata/snapshots/TestILikeArgs.golden | 2 + test/testdata/snapshots/TestILikeSQL.golden | 1 + .../snapshots/TestILikeSearchArgs.golden | 2 + .../snapshots/TestILikeSearchSQL.golden | 1 + .../snapshots/TestLeftOuterJoinArgs.golden | 0 .../snapshots/TestLeftOuterJoinSQL.golden | 1 + .../TestLeftOuterJoinWithFilterArgs.golden | 1 + .../TestLeftOuterJoinWithFilterSQL.golden | 1 + .../TestLogErrorFuncWithRequestInfo.golden | 1 + test/testdata/snapshots/TestNINArgs.golden | 5 + test/testdata/snapshots/TestNINSQL.golden | 1 + .../TestNotFound-AcceptApplicationJSON.golden | 1 + .../TestNotFound-AcceptTextHTML.golden | 1 + test/testdata/snapshots/TestOrArgs.golden | 10 + test/testdata/snapshots/TestOrSQL.golden | 1 + .../snapshots/TestParseModel_Success.golden | 89 + ...wordBadRequest-EmptyCurrentPassword.golden | 1 + ...PasswordBadRequest-EmptyNewPassword.golden | 1 + ...rdBadRequest-MissingCurrentPassword.golden | 1 + ...sswordBadRequest-MissingNewPassword.golden | 1 + ...gotPasswordBadRequest-EmptyUsername.golden | 1 + ...tPasswordBadRequest-InvalidUsername.golden | 1 + ...tPasswordBadRequest-MissingUsername.golden | 1 + ...ordCompleteBadRequest-EmptyPassword.golden | 1 + ...sswordCompleteBadRequest-EmptyToken.golden | 1 + ...wordCompleteBadRequest-InvalidToken.golden | 1 + ...dCompleteBadRequest-MissingPassword.golden | 1 + ...wordCompleteBadRequest-MissingToken.golden | 1 + ...stPostForgotPasswordCompleteSuccess.golden | 6 + ...stPostLoginBadRequest-EmptyPassword.golden | 1 + ...stPostLoginBadRequest-EmptyUsername.golden | 1 + ...PostLoginBadRequest-InvalidUsername.golden | 1 + ...PostLoginBadRequest-MissingPassword.golden | 1 + ...PostLoginBadRequest-MissingUsername.golden | 1 + .../TestPostLogoutInvalidRefreshToken.golden | 1 + ...RefreshBadRequest-EmptyRefreshToken.golden | 1 + ...tPostRefreshBadRequest-InvalidToken.golden | 1 + ...freshBadRequest-MissingRefreshToken.golden | 1 + ...ostRegisterBadRequest-EmptyPassword.golden | 1 + ...ostRegisterBadRequest-EmptyUsername.golden | 1 + ...tRegisterBadRequest-InvalidUsername.golden | 1 + ...tRegisterBadRequest-MissingPassword.golden | 1 + ...tRegisterBadRequest-MissingUsername.golden | 1 + .../TestSaveResponseAndValidate.golden | 8 + .../TestSaveResponseAndValidateJSON.golden | 8 + test/testdata/snapshots/TestSnapshot.golden | 7 + .../snapshots/TestSnapshotJSONJSON.golden | 19 + .../snapshots/TestSnapshotSaveBytesImage.jpg | Bin 0 -> 6749 bytes .../snapshots/TestSnapshotShouldFail.golden | 7 + .../snapshots/TestSnapshotSkipFields.golden | 7 + .../TestSnapshotSkipMultilineFields.golden | 9 + .../TestSnapshotSkipPrefixedFields.golden | 10 + .../snapshots/TestSnapshotWithLabel_A.golden | 6 + .../snapshots/TestSnapshotWithLabel_B.golden | 1 + .../snapshots/TestSnapshotWithReplacer.golden | 7 + .../snapshots/TestSnapshotWithUpdate.golden | 7 + .../testdata/snapshots/TestWhereInArgs.golden | 5 + test/testdata/snapshots/TestWhereInSQL.golden | 1 + .../snapshots/TestWhereJSONStringArgs.golden | 1 + .../snapshots/TestWhereJSONStringSQL.golden | 1 + .../snapshots/TestWhereJSONStructArgs.golden | 14 + .../TestWhereJSONStructCompositionArgs.golden | 8 + .../TestWhereJSONStructCompositionSQL.golden | 1 + .../snapshots/TestWhereJSONStructSQL.golden | 1 + test/testdata/users.sql | 90 + test/testdata/uuid_extension_only.sql | 69 + web/README.md | 13 + web/i18n/en.toml | 3 + .../account_confirmation.html.tmpl | 10 + .../password_reset/password_reset.html.tmpl | 10 + .../views/account_confirmation.html.tmpl | 30 + 461 files changed, 45002 insertions(+) create mode 100644 .changes-go-starter/24.11.06.md create mode 100644 .changes-go-starter/25.01.0.md create mode 100644 .changes-go-starter/25.01.1.md create mode 100644 .changes-go-starter/25.01.2.md create mode 100644 .changes-go-starter/25.01.3.md create mode 100644 .changes-go-starter/25.02.0.md create mode 100644 .changes-go-starter/25.02.1.md create mode 100644 .changes-go-starter/25.04.0.md create mode 100644 .changes-go-starter/25.04.1.md create mode 100644 .changes-go-starter/25.10.0.md create mode 100644 .changes-go-starter/header.tpl.md create mode 100644 .changes-go-starter/prerelease/.gitkeep create mode 100644 .changes-go-starter/unreleased/.gitkeep create mode 100644 .changie-go-starter.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .drone.yml create mode 100644 .env.local.sample create mode 120000 .github/README.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build-test.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/docker.env create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .spectral.yml create mode 100644 .trivyignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 api/README.md create mode 100644 api/config/go-swagger-config.yml create mode 100644 api/config/main.yml create mode 100644 api/config/swagger-ui-local-translator.js create mode 100644 api/definitions/auth.yml create mode 100644 api/definitions/common.yml create mode 100644 api/definitions/errors.yml create mode 100644 api/definitions/push.yml create mode 100644 api/paths/auth.yml create mode 100644 api/paths/common.yml create mode 100644 api/paths/push.yml create mode 100644 api/paths/well_known.yml create mode 100644 api/swagger.yml create mode 100644 api/templates/server/builder.README create mode 100644 api/templates/server/builder.gotmpl create mode 100644 api/templates/server/parameter.README create mode 100644 api/templates/server/parameter.gotmpl create mode 100644 assets/README.md create mode 100644 assets/mnt/.gitkeep create mode 100644 cmd/README.md create mode 100644 cmd/db/db.go create mode 100644 cmd/db/migrate.go create mode 100644 cmd/db/seed.go create mode 100644 cmd/env/env.go create mode 100644 cmd/probe/liveness.go create mode 100644 cmd/probe/probe.go create mode 100644 cmd/probe/readiness.go create mode 100644 cmd/root.go create mode 100644 cmd/server/server.go create mode 100644 dbconfig.yml create mode 100644 docker-compose.yml create mode 100755 docker-helper.sh create mode 100644 docs/README.md create mode 100644 docs/schemacrawler/.gitignore create mode 100644 docs/schemacrawler/README.md create mode 100644 docs/schemacrawler/schemacrawler.config.properties create mode 100644 docs/server-initialization.md create mode 100644 go.mod create mode 100644 go.not create mode 100644 go.sum create mode 100644 internal/README.md create mode 100644 internal/api/handlers/auth/delete_user_account.go create mode 100644 internal/api/handlers/auth/delete_user_account_test.go create mode 100644 internal/api/handlers/auth/get_complete_register.go create mode 100644 internal/api/handlers/auth/get_complete_register_test.go create mode 100644 internal/api/handlers/auth/get_userinfo.go create mode 100644 internal/api/handlers/auth/get_userinfo_test.go create mode 100644 internal/api/handlers/auth/post_change_password.go create mode 100644 internal/api/handlers/auth/post_change_password_test.go create mode 100644 internal/api/handlers/auth/post_complete_register.go create mode 100644 internal/api/handlers/auth/post_complete_register_test.go create mode 100644 internal/api/handlers/auth/post_forgot_password.go create mode 100644 internal/api/handlers/auth/post_forgot_password_complete.go create mode 100644 internal/api/handlers/auth/post_forgot_password_complete_test.go create mode 100644 internal/api/handlers/auth/post_forgot_password_test.go create mode 100644 internal/api/handlers/auth/post_login.go create mode 100644 internal/api/handlers/auth/post_login_test.go create mode 100644 internal/api/handlers/auth/post_logout.go create mode 100644 internal/api/handlers/auth/post_logout_test.go create mode 100644 internal/api/handlers/auth/post_refresh.go create mode 100644 internal/api/handlers/auth/post_refresh_test.go create mode 100644 internal/api/handlers/auth/post_register.go create mode 100644 internal/api/handlers/auth/post_register_test.go create mode 100644 internal/api/handlers/common/get_healthy.go create mode 100644 internal/api/handlers/common/get_healthy_test.go create mode 100644 internal/api/handlers/common/get_ready.go create mode 100644 internal/api/handlers/common/get_ready_test.go create mode 100644 internal/api/handlers/common/get_swagger.go create mode 100644 internal/api/handlers/common/get_swagger_test.go create mode 100644 internal/api/handlers/common/get_version.go create mode 100644 internal/api/handlers/common/get_version_test.go create mode 100644 internal/api/handlers/common/probes.go create mode 100644 internal/api/handlers/common/probes_internal_test.go create mode 100644 internal/api/handlers/constants/constants.go create mode 100644 internal/api/handlers/handlers.go create mode 100644 internal/api/handlers/push/put_update_push_token.go create mode 100644 internal/api/handlers/push/put_update_push_token_test.go create mode 100644 internal/api/handlers/wellknown/get_android_assetlinks.go create mode 100644 internal/api/handlers/wellknown/get_android_assetlinks_test.go create mode 100644 internal/api/handlers/wellknown/get_apple_app_site_association.go create mode 100644 internal/api/handlers/wellknown/get_apple_app_site_association_test.go create mode 100644 internal/api/httperrors/auth.go create mode 100644 internal/api/httperrors/common.go create mode 100644 internal/api/httperrors/error.go create mode 100644 internal/api/httperrors/error_test.go create mode 100644 internal/api/httperrors/push.go create mode 100644 internal/api/middleware/auth.go create mode 100644 internal/api/middleware/cache_control.go create mode 100644 internal/api/middleware/logger.go create mode 100644 internal/api/middleware/logger_test.go create mode 100644 internal/api/middleware/no_cache.go create mode 100644 internal/api/middleware/noop.go create mode 100644 internal/api/middleware/recover.go create mode 100644 internal/api/middleware/recover_test.go create mode 100644 internal/api/providers.go create mode 100644 internal/api/router/echo_logger.go create mode 100644 internal/api/router/echo_renderer.go create mode 100644 internal/api/router/handlers.go create mode 100644 internal/api/router/router.go create mode 100644 internal/api/router/router_test.go create mode 100644 internal/api/router/templates/templates.go create mode 100644 internal/api/server.go create mode 100644 internal/api/wire.go create mode 100644 internal/api/wire_gen.go create mode 100644 internal/auth/constants.go create mode 100644 internal/auth/context.go create mode 100644 internal/auth/errors.go create mode 100644 internal/auth/scopes.go create mode 100644 internal/auth/service.go create mode 100644 internal/auth/types.go create mode 100644 internal/config/build_args.go create mode 100644 internal/config/db_config.go create mode 100644 internal/config/db_config_test.go create mode 100644 internal/config/dot_env.go create mode 100644 internal/config/dot_env_test.go create mode 100644 internal/config/mailer_config.go create mode 100644 internal/config/server_config.go create mode 100644 internal/config/server_config_test.go create mode 100644 internal/config/service_config.go create mode 100644 internal/config/testdata/.env.local.malformed create mode 100644 internal/config/testdata/.env.local.sample create mode 100644 internal/config/testdata/.env1.local create mode 100644 internal/config/testdata/.env2.local create mode 100644 internal/data/dto/push.go create mode 100644 internal/data/dto/users.go create mode 100644 internal/data/fixtures/fixtures.go create mode 100644 internal/data/fixtures/fixtures_test.go create mode 100644 internal/data/local/service.go create mode 100644 internal/data/local/service_push.go create mode 100644 internal/data/mapper/users.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/i18n/testdata/README.md create mode 100644 internal/i18n/testdata/i18n-empty/.gitkeep create mode 100644 internal/i18n/testdata/i18n-invalid/en.toml create mode 100644 internal/i18n/testdata/i18n-plural/de.toml create mode 100644 internal/i18n/testdata/i18n-plural/en.toml create mode 100644 internal/i18n/testdata/i18n-reserved/de.toml create mode 100644 internal/i18n/testdata/i18n-reserved/en.toml create mode 100644 internal/i18n/testdata/i18n-specialized/de-at.toml create mode 100644 internal/i18n/testdata/i18n-specialized/de-de.toml create mode 100644 internal/i18n/testdata/i18n-specialized/en-uk.toml create mode 100644 internal/i18n/testdata/i18n-specialized/en-us.toml create mode 100644 internal/i18n/testdata/i18n-undetermined/xx.toml create mode 100644 internal/i18n/testdata/i18n/de.toml create mode 100644 internal/i18n/testdata/i18n/en.toml create mode 100644 internal/mailer/mailer.go create mode 100644 internal/mailer/mailer_test.go create mode 100644 internal/mailer/transport/mock.go create mode 100644 internal/mailer/transport/smtp.go create mode 100644 internal/mailer/transport/smtp_config.go create mode 100644 internal/mailer/transport/smtp_login_auth.go create mode 100644 internal/mailer/transport/transport.go create mode 100644 internal/metrics/service.go create mode 100644 internal/metrics/users/collector.go create mode 100644 internal/metrics/users/metrics.go create mode 100644 internal/models/access_tokens.go create mode 100644 internal/models/access_tokens_test.go create mode 100644 internal/models/app_user_profiles.go create mode 100644 internal/models/app_user_profiles_test.go create mode 100644 internal/models/boil_main_test.go create mode 100644 internal/models/boil_queries.go create mode 100644 internal/models/boil_queries_test.go create mode 100644 internal/models/boil_relationship_test.go create mode 100644 internal/models/boil_suites_test.go create mode 100644 internal/models/boil_table_names.go create mode 100644 internal/models/boil_types.go create mode 100644 internal/models/boil_view_names.go create mode 100644 internal/models/confirmation_tokens.go create mode 100644 internal/models/confirmation_tokens_test.go create mode 100644 internal/models/password_reset_tokens.go create mode 100644 internal/models/password_reset_tokens_test.go create mode 100644 internal/models/psql_main_test.go create mode 100644 internal/models/psql_suites_test.go create mode 100644 internal/models/psql_upsert.go create mode 100644 internal/models/push_tokens.go create mode 100644 internal/models/push_tokens_test.go create mode 100644 internal/models/refresh_tokens.go create mode 100644 internal/models/refresh_tokens_test.go create mode 100644 internal/models/users.go create mode 100644 internal/models/users_test.go create mode 100644 internal/persistence/postgres.go create mode 100644 internal/persistence/postgres_test.go create mode 100644 internal/push/provider/fcm_provider.go create mode 100644 internal/push/provider/helper.go create mode 100644 internal/push/provider/mock_provider.go create mode 100644 internal/push/service.go create mode 100644 internal/push/service_test.go create mode 100644 internal/test/README.md create mode 100644 internal/test/fixtures/fixtures.go create mode 100644 internal/test/fixtures/fixtures_test.go create mode 100644 internal/test/helper_compare.go create mode 100644 internal/test/helper_compare_test.go create mode 100644 internal/test/helper_dot_env.go create mode 100644 internal/test/helper_dot_env_test.go create mode 100644 internal/test/helper_files.go create mode 100644 internal/test/helper_files_test.go create mode 100644 internal/test/helper_mappers.go create mode 100644 internal/test/helper_mappers_test.go create mode 100644 internal/test/helper_request.go create mode 100644 internal/test/helper_snapshot.go create mode 100644 internal/test/helper_snapshot_test.go create mode 100644 internal/test/mocks/TestingT.go create mode 100644 internal/test/test_clock.go create mode 100644 internal/test/test_clock_test.go create mode 100644 internal/test/test_database.go create mode 100644 internal/test/test_database_test.go create mode 100644 internal/test/test_mailer.go create mode 100644 internal/test/test_mailer_test.go create mode 100644 internal/test/test_pusher.go create mode 100644 internal/test/test_server.go create mode 100644 internal/test/test_server_test.go create mode 100644 internal/test/testdata/.env.test.local create mode 100644 internal/test/testdata/TestSnapshotWithLocation.golden create mode 100644 internal/test/testing_mock.go create mode 100644 internal/types/auth/delete_user_account_route_parameters.go create mode 100644 internal/types/auth/get_complete_register_route_parameters.go create mode 100644 internal/types/auth/get_user_info_route_parameters.go create mode 100644 internal/types/auth/post_change_password_route_parameters.go create mode 100644 internal/types/auth/post_complete_register_route_parameters.go create mode 100644 internal/types/auth/post_forgot_password_complete_route_parameters.go create mode 100644 internal/types/auth/post_forgot_password_route_parameters.go create mode 100644 internal/types/auth/post_login_route_parameters.go create mode 100644 internal/types/auth/post_logout_route_parameters.go create mode 100644 internal/types/auth/post_refresh_route_parameters.go create mode 100644 internal/types/auth/post_register_route_parameters.go create mode 100644 internal/types/common/get_healthy_route_parameters.go create mode 100644 internal/types/common/get_ready_route_parameters.go create mode 100644 internal/types/common/get_swagger_route_parameters.go create mode 100644 internal/types/common/get_version_route_parameters.go create mode 100644 internal/types/delete_user_account_payload.go create mode 100644 internal/types/get_user_info_response.go create mode 100644 internal/types/http_validation_error_detail.go create mode 100644 internal/types/order_dir.go create mode 100644 internal/types/post_change_password_payload.go create mode 100644 internal/types/post_forgot_password_complete_payload.go create mode 100644 internal/types/post_forgot_password_payload.go create mode 100644 internal/types/post_login_payload.go create mode 100644 internal/types/post_login_response.go create mode 100644 internal/types/post_logout_payload.go create mode 100644 internal/types/post_refresh_payload.go create mode 100644 internal/types/post_register_payload.go create mode 100644 internal/types/public_http_error.go create mode 100644 internal/types/public_http_error_type.go create mode 100644 internal/types/public_http_validation_error.go create mode 100644 internal/types/push/put_update_push_token_route_parameters.go create mode 100644 internal/types/put_update_push_token_payload.go create mode 100644 internal/types/register_response.go create mode 100644 internal/types/spec_handlers.go create mode 100644 internal/types/well_known/get_android_digital_asset_links_route_parameters.go create mode 100644 internal/types/well_known/get_apple_app_site_association_route_parameters.go create mode 100644 internal/util/bool.go create mode 100644 internal/util/bool_test.go create mode 100644 internal/util/cache_control.go create mode 100644 internal/util/cache_control_test.go create mode 100644 internal/util/command/command.go create mode 100644 internal/util/command/command_test.go create mode 100644 internal/util/context.go create mode 100644 internal/util/context_test.go create mode 100644 internal/util/currency.go create mode 100644 internal/util/currency_test.go create mode 100644 internal/util/db/db.go create mode 100644 internal/util/db/db_test.go create mode 100644 internal/util/db/example_test.go create mode 100644 internal/util/db/ilike.go create mode 100644 internal/util/db/ilike_test.go create mode 100644 internal/util/db/join.go create mode 100644 internal/util/db/join_test.go create mode 100644 internal/util/db/json.go create mode 100644 internal/util/db/json_test.go create mode 100644 internal/util/db/or.go create mode 100644 internal/util/db/or_test.go create mode 100644 internal/util/db/order_by.go create mode 100644 internal/util/db/order_by_test.go create mode 100644 internal/util/db/query_mods.go create mode 100644 internal/util/db/ts_vector.go create mode 100644 internal/util/db/ts_vector_test.go create mode 100644 internal/util/db/where_in.go create mode 100644 internal/util/db/where_in_test.go create mode 100644 internal/util/env.go create mode 100644 internal/util/env_test.go create mode 100644 internal/util/fs.go create mode 100644 internal/util/fs_test.go create mode 100644 internal/util/get_project_root_dir.go create mode 100644 internal/util/get_project_root_dir_scripts.go create mode 100644 internal/util/hashing/argon2.go create mode 100644 internal/util/hashing/argon2_params.go create mode 100644 internal/util/hashing/argon2_test.go create mode 100644 internal/util/hashing/util.go create mode 100644 internal/util/http.go create mode 100644 internal/util/http_test.go create mode 100644 internal/util/int.go create mode 100644 internal/util/int_test.go create mode 100644 internal/util/lang.go create mode 100644 internal/util/lang_test.go create mode 100644 internal/util/log.go create mode 100644 internal/util/log_test.go create mode 100644 internal/util/map.go create mode 100644 internal/util/map_test.go create mode 100644 internal/util/mime/mime.go create mode 100644 internal/util/mime/mime_test.go create mode 100644 internal/util/oauth2/pkce.go create mode 100644 internal/util/oauth2/pkce_test.go create mode 100644 internal/util/path.go create mode 100644 internal/util/path_test.go create mode 100644 internal/util/slice.go create mode 100644 internal/util/slice_test.go create mode 100644 internal/util/string.go create mode 100644 internal/util/string_test.go create mode 100644 internal/util/struct.go create mode 100644 internal/util/struct_test.go create mode 100644 internal/util/time.go create mode 100644 internal/util/time_test.go create mode 100644 internal/util/url/url.go create mode 100644 internal/util/wait_group.go create mode 100644 internal/util/wait_group_test.go create mode 100644 main.go create mode 100644 migrations/20200428064736-install-extension-uuid.sql create mode 100644 migrations/20200428064802-create-user-scope-enum.sql create mode 100644 migrations/20200428072232-create-users.sql create mode 100644 migrations/20200428072359-create-app-user-profiles.sql create mode 100644 migrations/20200428072541-create-access-tokens.sql create mode 100644 migrations/20200428072639-create-refresh-tokens.sql create mode 100644 migrations/20200428072651-create-password-reset-tokens.sql create mode 100644 migrations/20200529112509-create-push-token.sql create mode 100644 migrations/20200811180414-create-health-sequence.sql create mode 100644 migrations/20250505074511-create-table-confirmation-tokens.sql create mode 100644 migrations/README.md create mode 100755 rksh create mode 100644 scripts/README.md create mode 100644 scripts/cmd/handlers.go create mode 100644 scripts/cmd/handlers_check.go create mode 100644 scripts/cmd/handlers_gen.go create mode 100644 scripts/cmd/modulename.go create mode 100644 scripts/cmd/root.go create mode 100644 scripts/cmd/scaffold.go create mode 100644 scripts/internal/handlers/check.go create mode 100644 scripts/internal/handlers/gen.go create mode 100644 scripts/internal/scaffold/scaffold.go create mode 100644 scripts/internal/scaffold/scaffold_test.go create mode 100644 scripts/internal/scaffold/templates.go create mode 100644 scripts/internal/scaffold/testdata/test_resource.txt create mode 100644 scripts/internal/util/get_module_name.go create mode 100644 scripts/internal/util/get_project_root_dir.go create mode 100644 scripts/main.go create mode 100644 scripts/sql/default_zero_values.sql create mode 100644 scripts/sql/fk_missing_index.sql create mode 100644 scripts/sql/info.sql create mode 100644 sqlboiler.toml create mode 100644 test/testdata/android-assetlinks.json create mode 100644 test/testdata/apple-app-site-association.json create mode 100644 test/testdata/example.jpg create mode 100644 test/testdata/plain.sql create mode 100644 test/testdata/snapshots/TestGetAndroidWellKnown.golden create mode 100644 test/testdata/snapshots/TestGetAppleWellKnown.golden create mode 100644 test/testdata/snapshots/TestGetCompleteRegister.golden create mode 100644 test/testdata/snapshots/TestGetUserInfo.golden create mode 100644 test/testdata/snapshots/TestILikeArgs.golden create mode 100644 test/testdata/snapshots/TestILikeSQL.golden create mode 100644 test/testdata/snapshots/TestILikeSearchArgs.golden create mode 100644 test/testdata/snapshots/TestILikeSearchSQL.golden create mode 100644 test/testdata/snapshots/TestLeftOuterJoinArgs.golden create mode 100644 test/testdata/snapshots/TestLeftOuterJoinSQL.golden create mode 100644 test/testdata/snapshots/TestLeftOuterJoinWithFilterArgs.golden create mode 100644 test/testdata/snapshots/TestLeftOuterJoinWithFilterSQL.golden create mode 100644 test/testdata/snapshots/TestLogErrorFuncWithRequestInfo.golden create mode 100644 test/testdata/snapshots/TestNINArgs.golden create mode 100644 test/testdata/snapshots/TestNINSQL.golden create mode 100644 test/testdata/snapshots/TestNotFound-AcceptApplicationJSON.golden create mode 100644 test/testdata/snapshots/TestNotFound-AcceptTextHTML.golden create mode 100644 test/testdata/snapshots/TestOrArgs.golden create mode 100644 test/testdata/snapshots/TestOrSQL.golden create mode 100644 test/testdata/snapshots/TestParseModel_Success.golden create mode 100644 test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyCurrentPassword.golden create mode 100644 test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyNewPassword.golden create mode 100644 test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingCurrentPassword.golden create mode 100644 test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingNewPassword.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordBadRequest-EmptyUsername.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordBadRequest-InvalidUsername.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordBadRequest-MissingUsername.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyPassword.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyToken.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-InvalidToken.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingPassword.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingToken.golden create mode 100644 test/testdata/snapshots/TestPostForgotPasswordCompleteSuccess.golden create mode 100644 test/testdata/snapshots/TestPostLoginBadRequest-EmptyPassword.golden create mode 100644 test/testdata/snapshots/TestPostLoginBadRequest-EmptyUsername.golden create mode 100644 test/testdata/snapshots/TestPostLoginBadRequest-InvalidUsername.golden create mode 100644 test/testdata/snapshots/TestPostLoginBadRequest-MissingPassword.golden create mode 100644 test/testdata/snapshots/TestPostLoginBadRequest-MissingUsername.golden create mode 100644 test/testdata/snapshots/TestPostLogoutInvalidRefreshToken.golden create mode 100644 test/testdata/snapshots/TestPostRefreshBadRequest-EmptyRefreshToken.golden create mode 100644 test/testdata/snapshots/TestPostRefreshBadRequest-InvalidToken.golden create mode 100644 test/testdata/snapshots/TestPostRefreshBadRequest-MissingRefreshToken.golden create mode 100644 test/testdata/snapshots/TestPostRegisterBadRequest-EmptyPassword.golden create mode 100644 test/testdata/snapshots/TestPostRegisterBadRequest-EmptyUsername.golden create mode 100644 test/testdata/snapshots/TestPostRegisterBadRequest-InvalidUsername.golden create mode 100644 test/testdata/snapshots/TestPostRegisterBadRequest-MissingPassword.golden create mode 100644 test/testdata/snapshots/TestPostRegisterBadRequest-MissingUsername.golden create mode 100644 test/testdata/snapshots/TestSaveResponseAndValidate.golden create mode 100644 test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden create mode 100644 test/testdata/snapshots/TestSnapshot.golden create mode 100644 test/testdata/snapshots/TestSnapshotJSONJSON.golden create mode 100644 test/testdata/snapshots/TestSnapshotSaveBytesImage.jpg create mode 100644 test/testdata/snapshots/TestSnapshotShouldFail.golden create mode 100644 test/testdata/snapshots/TestSnapshotSkipFields.golden create mode 100644 test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden create mode 100644 test/testdata/snapshots/TestSnapshotSkipPrefixedFields.golden create mode 100644 test/testdata/snapshots/TestSnapshotWithLabel_A.golden create mode 100644 test/testdata/snapshots/TestSnapshotWithLabel_B.golden create mode 100644 test/testdata/snapshots/TestSnapshotWithReplacer.golden create mode 100644 test/testdata/snapshots/TestSnapshotWithUpdate.golden create mode 100644 test/testdata/snapshots/TestWhereInArgs.golden create mode 100644 test/testdata/snapshots/TestWhereInSQL.golden create mode 100644 test/testdata/snapshots/TestWhereJSONStringArgs.golden create mode 100644 test/testdata/snapshots/TestWhereJSONStringSQL.golden create mode 100644 test/testdata/snapshots/TestWhereJSONStructArgs.golden create mode 100644 test/testdata/snapshots/TestWhereJSONStructCompositionArgs.golden create mode 100644 test/testdata/snapshots/TestWhereJSONStructCompositionSQL.golden create mode 100644 test/testdata/snapshots/TestWhereJSONStructSQL.golden create mode 100644 test/testdata/users.sql create mode 100644 test/testdata/uuid_extension_only.sql create mode 100644 web/README.md create mode 100644 web/i18n/en.toml create mode 100644 web/templates/email/account_confirmation/account_confirmation.html.tmpl create mode 100644 web/templates/email/password_reset/password_reset.html.tmpl create mode 100644 web/templates/views/account_confirmation.html.tmpl diff --git a/.changes-go-starter/24.11.06.md b/.changes-go-starter/24.11.06.md new file mode 100644 index 0000000..229eee9 --- /dev/null +++ b/.changes-go-starter/24.11.06.md @@ -0,0 +1,758 @@ +## 2024-06-11 +- Update to [golang:1.22.4-bookworm](https://hub.docker.com/layers/library/golang/1.22.4-bookworm/images/sha256-5eb6d52cd78951fa53884e208a030db506ab619a13157b9b7ea3a8eb5e4cbeee?context=explore) (requires `./docker-helper.sh --rebuild`) containing the bump from Debian `bullseye`(11) to `bookworm` (12) + - Minor: [Bump github.com/golangci/golangci-lint from 1.55.2 to 1.59.0](https://github.com/golangci/golangci-lint/releases/tag/v1.59.0) + - Minor: [Bump github.com/gotestyourself/gotestsum from 1.11.0 to 1.12.0](https://github.com/gotestyourself/gotestsum/releases/tag/v1.12.0) +- Extended and fixed the password reset handling by a debounce and reuse duration. This can for example be leveraged to mitigate email flooding. The token reuse fixes an existing solution that was not working due to searching for tokens created on minute in the future instead of the past using `models.PasswordResetTokenWhere.CreatedAt.GT(time.Now().Add(time.Minute*1)),`. The new default behaviour is to debounce the password reset by 60 seconds and not to reuse reset tokens: + - `PasswordResetTokenDebounceDuration` / `SERVER_AUTH_PASSWORD_RESET_TOKEN_DEBOUNCE_DURATION_SECONDS`: if a password reset token has been created in this duration, no password reset is initialized (default: 60 seconds) + - `PasswordResetTokenReuseDuration` / `SERVER_AUTH_PASSWORD_RESET_TOKEN_REUSE_DURATION_SECONDS`: if a password reset token has been created in this duration and is still valid, it is reused instead of re-created (default-value: 0 seconds->no reuse) +- Added test helper to simplify assertion of `httperrors.HTTPError` verifying the http status code and the returned error, example usage: +```go +res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil) +response := test.RequireHTTPError(t, res, httperrors.ErrNotFoundTokenNotFound) +``` +- Added helpers to get last sent emails from mock transport using `mail := test.GetLastSentMail(t, s.Mailer)` or `sentMails := test.GetSentMails(t, s.Mailer)` +- Bumped Github Actions `github/codeql-action` from v1 to v3 and set go version in `go.mod` to `1.22.4` due to https://github.com/github/codeql/issues/15647 + +## 2024-05-28 +- Fixes the `LogErrorFuncWithRequestInfo` to return the error in order to pass the error to the global error handling mechanism + +## 2024-05-14 +- Switch [from Go 1.21.6 Go 1.21.10](https://go.dev/doc/devel/release#go1.21.0) (requires `./docker-helper.sh --rebuild`). +- Extended the snapshot and request helper to improve the test experience when snapshotting raw JSON (`.SaveJSON`) and raw bytes like images (`.SaveBytes`) +- Improved custom replace function in snapshot helper to only redact explicit matches +- Added custom LogErrorFunc for recover middleware to attach request info the recover log messages +- Adjusted order of validation error matching to correctly return list of errors wrapped in single error +- Added vulnerability scanning to dev container (trivy and govulncheck) + +## 2024-02-01 +- [Persist bash history in development container](https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history) (requires `./docker-helper.sh --rebuild`). + - Your commands are now persisted between your development container restarts / rebuilds, making it easier to re-run specific commands you've previously executed (e.g. that one go command you cannot remember). +- Hotfix [types.NullDecimal error](https://github.com/volatiletech/sqlboiler/issues/1234) by downgrading indirect `github.com/ericlagergren/decimal@v0.0.0-20190420051523-6335edbaa640`. + - Note that we do not pin it in direct dependencies, as this downgrade is already in [SQLBoilers master](https://github.com/volatiletech/sqlboiler/commit/bc59c158590800f7810cce241a12d572e898014f) anyways. + +## 2024-01-31 +- Migration to Docker Compose V2 ([Docker Compose Docs](https://docs.docker.com/compose/reference/)), thx [@eklatzer](https://github.com/eklatzer) +- Upgrade to [IntegreSQL v1.1.0](https://github.com/allaboutapps/integresql/blob/v1.1.0/CHANGELOG.md#v110) +- Switch [from Go 1.20.3 Go 1.21.6](https://go.dev/doc/devel/release#go1.21.0) (requires `./docker-helper.sh --rebuild`). +- Fix premature optimization in `make swagger` -> `make swagger-generate` (rm `rsync` with `--size-only`), thx [@eklatzer](https://github.com/eklatzer) +- Dockerfile deps upgrade: + - Upgrade pgFormatter [from v5.3 to v5.5](https://github.com/darold/pgFormatter/releases/tag/v5.5) + - Upgrade gotestsum [from 1.9.0 to 1.11.0](https://github.com/gotestyourself/gotestsum/releases/tag/v1.11.0) + - Upgrade golangci-lint [from 1.52.2 to 1.55.2](https://github.com/golangci/golangci-lint/releases/tag/v1.55.2) + - Upgrade watchexec [from 1.20.6 to 1.25.1](https://github.com/watchexec/watchexec/releases/tag/v1.25.1) +- `go.mod` upgrades + - Minor: [Bump github.com/BurntSushi/toml from v1.2.1 to v1.3.2](https://github.com/BurntSushi/toml) + - Minor: [Bump github.com/davecgh/go-spew from v1.1.1 to v1.1.2-0.20180830191138-d8f796af33cc](https://github.com/davecgh/go-spew/commit/d8f796af33cc11cb798c1aaeb27a4ebc5099927d) + - Minor: [Bump github.com/gabriel-vasile/mimetype from v1.4.1 to v1.4.3](https://github.com/gabriel-vasile/mimetype) + - Minor: [Bump github.com/go-openapi/errors from v0.20.3 to v0.21.0](https://github.com/go-openapi/errors) + - Minor: [Bump github.com/go-openapi/runtime from v0.25.0 to v0.27.1](https://github.com/go-openapi/runtime) + - Minor: [Bump github.com/go-openapi/strfmt from v0.21.3 to v0.22.0](https://github.com/go-openapi/strfmt) + - Minor: [Bump github.com/go-openapi/swag from v0.22.3 to v0.22.9](https://github.com/go-openapi/swag) + - Minor: [Bump github.com/go-openapi/validate from v0.22.0 to v0.22.6](https://github.com/go-openapi/validate) + - Minor: [Bump github.com/labstack/echo/v4 from v4.9.1 to v4.11.4](https://github.com/labstack/echo) + - Minor: [Bump github.com/lib/pq from v1.10.7 to v1.10.9](https://github.com/lib/pq) + - Minor: [Bump github.com/nicksnyder/go-i18n/v2 from v2.2.1 to v2.4.0](https://github.com/nicksnyder/go-i18n) + - Minor: [Bump github.com/pmezard/go-difflib from v1.0.0 to v1.0.1-0.20181226105442-5d4384ee4fb2](https://github.com/pmezard/go-difflib) (deprecated) + - Minor: [Bump github.com/rs/zerolog from v1.28.0 to v1.31.0](https://github.com/rs/zerolog) + - Minor: [Bump github.com/rubenv/sql-migrate from v1.2.0 to v1.6.1](https://github.com/rubenv/sql-migrate) + - Minor: [Bump github.com/spf13/cobra from v1.6.1 to v1.8.0](https://github.com/spf13/cobra) + - Minor: [Bump github.com/spf13/viper from v1.14.0 to v1.18.2](https://github.com/spf13/viper) + - Minor: [Bump github.com/stretchr/testify from v1.8.1 to v1.8.4](https://github.com/stretchr/testify) + - Minor: [Bump github.com/subosito/gotenv from v1.4.1 to v1.6.0](https://github.com/subosito/gotenv) + - Minor: [Bump github.com/volatiletech/sqlboiler/v4 from v4.13.0 to v4.16.1](https://github.com/volatiletech/sqlboiler) + - Minor: [Bump github.com/volatiletech/strmangle from v0.0.4 to v0.0.6](https://github.com/volatiletech/strmangle) + - Minor: [Bump golang.org/x/crypto from v0.3.0 to v0.18.0](https://golang.org/x/crypto) + - Minor: [Bump golang.org/x/sys from v0.5.0 to v0.16.0](https://golang.org/x/sys) + - Minor: [Bump golang.org/x/text from v0.7.0 to v0.14.0](https://golang.org/x/text) + - Minor: [Bump google.golang.org/api from v0.103.0 to v0.161.0](https://google.golang.org/api) + - Minor: [Bump xxxx from yyy to zzz](https://xxxx) + - Replace: [github.com/rogpeppe/go-internal v1.9.0](https://github.com/rogpeppe/go-internal) with [golang.org/x/mod v0.14.0](https://pkg.go.dev/golang.org/x/mod) + +## 2023-05-03 +- Switch [from Go 1.19.3 to Go 1.20.3](https://go.dev/doc/devel/release#go1.20) (requires `./docker-helper.sh --rebuild`). +- Add new log configuration: + - optional `output` param of `LoggerWithConfig` to redirect the log output + - optional caller info switched on with `SERVER_LOGGER_LOG_CALLER` +- Minor: rename unused function parameters to fix linter errors +- Minor: update devcontainer.json syntax to remove deprecation warning +- Minor: add `GetFieldsImplementing` to utils and use it to easier add new fixture fields. +- `go.mod` changes: + - Minor: [Bump github.com/golangci/golangci-lint from 1.50.1 to 1.52.2](https://github.com/golangci/golangci-lint/releases/tag/v1.52.2) + - Minor: [Bump golang.org/x/net from 0.2.0 to 0.7.0](https://cs.opensource.google/go/x/net) (Fixing CVE-2022-41723) + +## 2023-03-03 +- Switch [from Go 1.17.9 to Go 1.19.3](https://go.dev/doc/devel/release#go1.19) (requires `./docker-helper.sh --rebuild`). + - Major: Update base docker image from debian buster to bullseye + - Minor: [Bump github.com/darold/pgFormatter from 5.2 to 5.3](https://github.com/darold/pgFormatter/releases/tag/v5.3) + - Minor: [Bump github.com/gotestyourself/gotestsum from 1.8.0 to 1.9.0](https://github.com/gotestyourself/gotestsum/releases/tag/v1.9.0) + - Minor: [Bump github.com/golangci/golangci-lint from 1.45.2 to 1.50.1](https://github.com/golangci/golangci-lint/releases/tag/v1.50.1) + - Minor: [Bump github.com/uw-labs/lichen from 0.1.5 to 0.1.7](https://github.com/uw-labs/lichen/releases/tag/v0.1.7) + - Minor: [Bump github.com/watchexec/watchexec from 1.18.11 to 1.20.6](https://github.com/watchexec/watchexec/releases/tag/v1.20.6) + - Minor: [Bump github.com/mikefarah/yq from 4.24.2 to 4.30.5](https://github.com/mikefarah/yq/releases/tag/v4.30.5) +- Major: Upgrade distroless app image from base-debian10 to base-debian11 +- Major: Dockerfile is now build to support amd64 and arm64 architecture +- Improve speed of `make swagger` when dealing with many files in `/api` by generating to a docker volume instead of the host filesystem, rsyncing only to changes into `/internal/types`. Furthermore split our swagger type generation and validation into two separate make targets, that can run concurrently (requires `./docker-helper.sh --rebuild`). + - Note that `/app/api/tmp`, `/app/tmp` and `/app/bin` are now baked by proper docker volumes when using our `docker-compose.yml`/`./docker-helper.sh --up`. You **cannot** remove these directories directly inside the container (but its contents) and you can also no longer see its files on your host machine directly! +- Fix `make check-gen-dirs` false positives hidden files. +- Allow to trace/benchmark `Makefile` targets execution by using a custom shell wrapper for make execution. See `SHELL` and `.SHELLFLAGS` within `Makefile` and the custom `rksh` script in the root working directory. Usage: `MAKE_TRACE_TIME=true make ` +- `go.mod` changes: + - Minor: [Bump github.com/BurntSushi/toml from 1.1.0 to 1.2.1](https://github.com/BurntSushi/toml/releases/tag/v1.2.1) + - Minor: [Bump github.com/gabriel-vasile/mimetype from 1.4.0 to 1.4.1](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.4.1) + - Minor: [Bump github.com/go-openapi/errors from 0.20.2 to 0.20.3](https://github.com/go-openapi/errors/releases/tag/v0.20.3) + - Minor: [Bump github.com/go-openapi/runtime from 0.23.3 to 0.25.0](https://github.com/go-openapi/runtime) + - Minor: [Bump github.com/go-openapi/strfmt from 0.21.2 to 0.21.3](https://github.com/go-openapi/strfmt/releases/tag/v0.21.3) + - Minor: [Bump github.com/go-openapi/swag from 0.21.1 to 0.22.3](https://github.com/go-openapi/swag/releases/tag/v0.22.3) + - Minor: [Bump github.com/go-openapi/validate from 0.21.0 to 0.22.0](https://github.com/go-openapi/validate/releases/tag/v0.22.0) + - Minor: [Bump github.com/labstack/echo/v4 from 4.7.2 to 4.9.1](https://github.com/labstack/echo/releases/tag/v4.9.1) (Fixing CVE-2022-40083) + - Minor: [Bump github.com/lib/pq from 1.10.5 to 1.10.7](https://github.com/lib/pq/releases/tag/v1.10.7) + - Minor: [Bump github.com/nicksnyder/go-i18n/v2 from 2.2.0 to 2.2.1](https://github.com/nicksnyder/go-i18n/releases/tag/v2.2.1) + - Minor: [Bump github.com/rogpeppe/go-internal from 1.8.1 to 1.9.0](https://github.com/rogpeppe/go-internal/releases/tag/v1.9.0) + - Minor: [Bump github.com/rs/zerolog from 1.26.1 to 1.28.0](https://github.com/rs/zerolog/releases/tag/v1.28.0) + - Minor: [Bump github.com/rubenv/sql-migrate from 1.1.1 to 1.2.0](https://github.com/rubenv/sql-migrate/releases/tag/v1.2.0) + - Minor: [Bump github.com/spf13/cobra from 1.4.0 to 1.6.1](https://github.com/spf13/cobra/releases/tag/v1.6.1) + - Minor: [Bump github.com/spf13/viper from 1.10.1 to 1.14.0](https://github.com/spf13/viper/releases/tag/v1.14.0) + - Minor: [Bump github.com/stretchr/testify from 1.7.1 to 1.8.1](https://github.com/stretchr/testify/releases/tag/v1.8.1) + - Minor: [Bump github.com/subosito/gotenv from 1.2.0 to 1.4.1](https://github.com/subosito/gotenv/releases/tag/v1.4.1) + - Minor: [Bump github.com/volatiletech/sqlboiler/v4 from 4.9.2 to v4.13.0](https://github.com/volatiletech/sqlboiler/blob/master/CHANGELOG.md#v4130---2022-08-28) + - Minor: [Bump github.com/volatiletech/strmangle from 0.0.2 to 0.0.4](https://github.com/volatiletech/strmangle/releases/tag/v0.0.4) (changes in enum generation might require manual changes, minor changes) + - Minor: [Bump golang.org/x/crypto from v0.0.0-20220411220226-7b82a4e95df4 to 0.3.0](https://cs.opensource.google/go/x/crypto) + - Minor: [Bump golang.org/x/sys from v0.0.0-20220412211240-33da011f77ad to 0.2.0](https://cs.opensource.google/go/x/sys) + - Minor: [Bump golang.org/x/text from 0.3.7 to 0.4.0](https://cs.opensource.google/go/x/text) (Fixing CVE-2022-32149) + - Minor: [Bump google.golang.org/api from 0.74.0 to 0.103.0](https://github.com/googleapis/google-api-go-client/compare/v0.80.0...v0.103.0) + +## 2022-09-13 +- Hotfix: Previously there was a chance of recursive error wrapping within our [`internal/api/router/error_handler.go`](https://github.com/allaboutapps/go-starter/blob/master/internal/api/router/error_handler.go) in combination with `*echo.HTTPError`. We currently disable this wrapping (as not used anyways) and will schedule a cleaner update regarding this error augmentation approach. + +## 2022-04-15 +- Switch [from Go 1.17.1 to Go 1.17.9](https://go.dev/doc/devel/release#go1.17.minor) (requires `./docker-helper.sh --rebuild`). +- **BREAKING** Add [`tenv`](https://github.com/sivchari/tenv) and [`errorlint`](https://github.com/polyfloyd/go-errorlint) linter to our default `.golangci.yml` configuration. + - We switch from `os.Setenv` to [`t.Setenv`](https://pkg.go.dev/testing#T.Setenv) within our own test code. + - **NOTE**: If you have used `os.Setenv` within your `*_test.go` code previously, simply replace those calls by `t.Setenv`. + - **NOTE**: The go-starter base code now properly uses `errors.Is` and `errors.As` for comparisons (and `%w` wrapping where really needed). For a good overview regarding error handling see [Effective Error Handling in Golang](https://earthly.dev/blog/golang-errors/). For example, if you receive linting errors, you'll need to change your code like this: + - Wrong: `if err == sql.ErrNoRows {` + - Valid: `if errors.Is(err, sql.ErrNoRows) {` + - Wrong: `if err != sql.ErrConnDone {` + - Valid: `if !errors.Is(err, sql.ErrConnDone) {` + - Wrong: `gErr := err.(*googleapi.Error)`, Valid: + - `var gErr *googleapi.Error` + - `ok := errors.As(err, &gErr)` +- `Dockerfile` development stage changes (requires `./docker-helper.sh --rebuild`): + - Bump [golang](https://hub.docker.com/_/golang) base image from `golang:1.17.1-buster` to **`golang:1.17.8-buster`**. + - Bump [pgFormatter](https://github.com/darold/pgFormatter) from v5.0 to [v5.2](https://github.com/darold/pgFormatter/releases/tag/v5.2) + - Bump [golangci-lint](https://github.com/golangci/golangci-lint) from v1.42.1 to [v1.45.2](https://github.com/golangci/golangci-lint/blob/master/CHANGELOG.md#v1452) + - Bump [lichen](https://github.com/uw-labs/lichen) from v0.1.4 to [v0.1.5](https://github.com/uw-labs/lichen/compare/v0.1.4...v0.1.5) + - Bump [watchexec](https://github.com/watchexec/watchexec) from v1.17.0 to [v1.18.11](https://github.com/watchexec/watchexec/releases/tag/cli-v1.18.11) (+ switch from gnu to musl) + - Bump [yq](https://github.com/mikefarah/yq) from v4.16.2 to [v4.24.2](https://github.com/mikefarah/yq/releases/tag/v4.24.2) + - Bump [gotestsum](https://github.com/gotestyourself/gotestsum) from v1.7.0 to [v1.8.0](https://github.com/gotestyourself/gotestsum/releases/tag/v1.8.0) + - Adds [tmux](https://github.com/tmux/tmux) (debian apt managed) +- `go.mod` changes: + - Major: [Bump `github.com/rubenv/sql-migrate` from v0.0.0-20210614095031-55d5740dbbcc to v1.1.1](https://github.com/rubenv/sql-migrate/compare/55d5740dbbccbaa4934009263b37ba52d837241f...v1.1.1) (though this should not lead to any major changes) + - Minor: [Bump github.com/volatiletech/sqlboiler/v4 from 4.6.0 to v4.9.2](https://github.com/volatiletech/sqlboiler/blob/v4.9.2/CHANGELOG.md#v492---2022-04-11) (your generated model might slightly change, minor changes). + - Note that v5 will prefer wrapping errors (e.g. `sql.ErrNoRows`) to retain the stack trace, thus it's about time for us to start to enforce proper `errors.Is` checks in our codebase (see above). + - Minor: [#178: Bump github.com/labstack/echo/v4 from 4.6.1 to 4.7.2](https://github.com/allaboutapps/go-starter/pull/178) (support for HEAD method query params binding, minor changes). + - Minor: [#160: Bump github.com/rs/zerolog from 1.25.0 to 1.26.1](https://github.com/allaboutapps/go-starter/pull/160) (minor changes). + - Minor: [#179: Bump github.com/nicksnyder/go-i18n/v2 from 2.1.2 to 2.2.0](https://github.com/allaboutapps/go-starter/pull/179) (minor changes). + - Minor: [Bump `github.com/gabriel-vasile/mimetype` from v1.3.1 to v1.4.0](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.4.0) + - Minor: [Bump `github.com/go-openapi/runtime` from v0.22.0 to v0.23.3](https://github.com/go-openapi/runtime/compare/v0.22.0...v0.23.3) + - Patch: [Bump `github.com/go-openapi/strfmt` from v0.21.1 to v0.21.2](https://github.com/go-openapi/strfmt/compare/v0.21.1...v0.21.2) + - Patch: [Bump `github.com/go-openapi/validate` from v0.20.3 to v0.21.0](https://github.com/go-openapi/validate/compare/v0.20.3...v0.21.0) + - Patch: [Bump `github.com/lib/pq` from v1.10.3 to v1.10.5](https://github.com/lib/pq/compare/v1.10.3...v1.10.5) + - Patch: [Bump `github.com/rogpeppe/go-internal` from v1.8.0 to v1.8.1](https://github.com/rogpeppe/go-internal/releases/tag/v1.8.1) + - Patch: [Bump `github.com/stretchr/testify` from v1.7.0 to v1.7.1](https://github.com/stretchr/testify/compare/v1.7.0...v1.7.1) + - Patch: [Bump `github.com/volatiletech/strmangle` from v0.0.1 to v0.0.2](https://github.com/volatiletech/strmangle/compare/v0.0.1...v0.0.2) + - Minor: [Bump `google.golang.org/api` from v0.63.0 to v0.74.0](https://github.com/googleapis/google-api-go-client/compare/v0.63.0...v0.74.0) + - Minor: [Bump `github.com/BurntSushi/toml` from v1.0.0 to v1.1.0](https://github.com/BurntSushi/toml/releases/tag/v1.1.0) + - Bump `golang.org/x/crypto` from v0.0.0-20211215165025-cf75a172585e to v0.0.0-20220411220226-7b82a4e95df4 + - Bump `golang.org/x/sys` from v0.0.0-20211210111614-af8b64212486 to v0.0.0-20220412211240-33da011f77ad +- We now support overriding `ENV` variables during **local** development through a `.env.local` dotenv file. + - This does not require a development container restart. + - We override the env within the app process through `config.DefaultServiceConfigFromEnv()`, so this does not mess with the actual container ENV. + - See `.env.local.sample` for further instructions to use this. + - Note that `.env.local` is **NEVER automatically** applied during **test runs**. If you really need that, use the specialized `test.DotEnvLoadLocalOrSkipTest` helper before loading up your server within that very test! This ensures that this test is automatically skipped if the `.env.local` file is no longer available. +- VSCode windows closes now explicitly stop Docker containers via [`shutdownAction: "stopCompose"`](https://code.visualstudio.com/docs/remote/devcontainerjson-reference) within `.devcontainer.json`. + - Use `./docker-helper --halt` or other `docker` or `docker-compose` management commands to do this explicitly instead. +- Drone CI specific (minor): Fix multiline ENV variables were messing up our `.hostenv` for `docker run` command testing of the final image. + +## 2022-03-28 + +- Merged [#165: Allow use of db.join* methods more than once](https://github.com/allaboutapps/go-starter/pull/165), thx [danut007ro](https://github.com/danut007ro). +- Merged [#169: Switch to standalone cobra-cli dependency](https://github.com/allaboutapps/go-starter/pull/169), thx [liggitt](https://github.com/liggitt) (requires `./docker-helper.sh --rebuild`). + - [`github.com/spf13/cobra@v1.4.0`](https://github.com/spf13/cobra/releases/tag/v1.4.0) split into `cobra` (the lib) and [`github.com/spf13/cobra-cli`](https://github.com/spf13/cobra-cli/releases) (the generator / scaffolding tool) + - We'll now depend on `cobra-cli` directly in our `Dockerfile`, while the core `cobra` dependency stays unchanged within our `go.mod`. + - Bumps [`github.com/spf13/cobra`](https://github.com/spf13/cobra) from v1.3.0 to [v1.4.0](https://github.com/spf13/cobra/releases/tag/v1.4.0) +- Fixed `test.ApplyMigrations` when combined with the import SQL dump mechanics in the testing context. + - Previously, we did still use the default [sql-migrate](https://github.com/rubenv/sql-migrate) `gorp_migrations` table to track applied migrations in our test databases, not our typical `migrations` table used everywhere else. + - This especially lead to problems when importing (production / live) SQL dumps via `test.WithTestDatabaseFromDump*`, `test.WithTestServerFromDump*` or `test.WithTestServerConfigurableFromDump` as our implementation tried to apply **all migrations** every time, regardless if a partial migration set was already applied previously (as the already applied migrations were not tracked within the `migrations` table (but within `gorp_migrations`) we did not notice). + - We now initialize this pipeline correctly in the test context (similar to our usage within `cmd/db_migrate.go` or `app db migrate`) and explicitly set these globals through `config.DatabaseMigrationTable` and `config.DatabaseMigrationFolder`. + - If you encounter problems after the upgrade, please execute `make sql-drop-all` in your local environment to reset the IntegreSQL test databases, then run `make sql-reset && make sql-spec-reset && make sql-spec-migrate && make all` to rebuild and test. + + +## 2022-02-28 + +### Changed + +- **BREAKING** Username format change in auth handlers + - Added the `util.ToUsernameFormat` helper function, which will **lowercase** and **trim whitespaces**. We use it to format usernames in the login, register, and forgot-password handlers. + - This prevents user duplication (e.g. two accounts registered with the same email address with different casing) and + - cases where users would inadvertently register with specific casing or a trailing whitespace after their username, and subsequently struggle to log into their account. + - **This effectively locks existing users whose username contains uppercase characters and/or whitespaces out of their accounts.** + - Before rolling out this change, check whether any existing users are affected and migrate their usernames to a format that is compatible with this change. + - Be aware that this may cause conflicts in regard to the uniqueness constraint of usernames and therefore need to be resolved manually, which is why we are not including a database migration to automatically migrate existing usernames to the new format. + - For more information and a possible manual database migration flow please see this special WIKI page: https://github.com/allaboutapps/go-starter/wiki/2022-02-28 + +## 2022-02-03 + +### Changed + +- Changed order of make targets in the `make swagger` pipeline. `make swagger-lint-ref-siblings` will now run after `make swagger-concat`, always linting the current version of our swagger file. This helps avoid errors regarding an invalid `swagger.yml` when resolving merge conflicts as those are often resolved by running `make swagger` and generating a fresh `swagger.yml`. + +## 2022-02-02 + +### Changed + +- Upgrades to [go-swagger](https://github.com/go-swagger/go-swagger) from to v0.26.1 to [v0.29.0](https://github.com/go-swagger/go-swagger/releases/tag/v0.29.0) (development stage only, requires `./docker-helper.sh --rebuild`). Includes the following `go.mod` upgrades: + - [github.com/go-openapi/runtime](https://github.com/go-openapi/runtime) from v0.19.31 to v0.22.0 + - [github.com/go-openapi/strfmt](https://github.com/go-openapi/strfmt) from v0.20.2 to v0.21.1 + - [github.com/go-openapi/validate](https://github.com/go-openapi/validate) from v0.20.2 to v0.20.3 + - [github.com/go-openapi/errors](https://github.com/go-openapi/errors) from v0.20.1 to v0.20.2 + - [github.com/go-openapi/swag](https://github.com/go-openapi/swag) from v0.19.15 to v0.21.1 +- Adds `yq` ([yq: a lightweight and portable command-line YAML processor](https://github.com/mikefarah/yq)) to our `Dockerfile` (development stage only, requires `./docker-helper.sh --rebuild`). +- Adds `make swagger-lint-ref-siblings` which is now executed as part of the `make build` (and `make swagger`) pipeline. + - Any sibling elements of a Swagger `$ref` are ignored. + - We have seen several misuses of `$ref` in our projects causing weird merge/flatten behaviors, thus we now lint for this case explicitly. + - Having `$ref` and sibling elements (e.g. `required`, `example`, ...) is unsupported by [OpenAPI v2: $ref and Sibling Elements](https://swagger.io/docs/specification/using-ref/) itself and the [JSON Reference specification](https://datatracker.ietf.org/doc/html/rfc3986) itself. + - To mitigate these errors, either expand the referenced element (fully remove `$ref`) or create a new element including your custom siblings elements and `$ref` this new one. +- Fix schema visualization generation guide in `docs/schemacrawler/README.md` + +## 2021-12-14 + +### Changed +- Add i18n service wrapping `go-i18n` package by nicksnyder. + - Allows parsing of Accept-Language header and language string. + - Support for templating using go templating language in message values. + - Support for [CLDR plural keys](https://cldr.unicode.org/index/cldr-spec/plural-rules) + - Added environment variables to configure i18n service + - `SERVER_I18N_DEFAULT_LANGUAGE` - set default language for i18n service + - `SERVER_I18N_BUNDLE_DIR_ABS` - set directory of i81n messages, available languages are automatically configured by the files present in the folder + +## 2021-11-29 + +### Changed + +- The `integresql` service previously bound its port (`5000`) to the host machine. As this conflicts with newer macOS releases and is not necessary for the development workflow, the port is now only exposed to the linked services. + +## 2021-10-22 + +### Changed + +- Fixes minor `Makefile` typos. +- New go-starter releases are now git tagged (starting from the previous release `go-starter-2021-10-19` onwards). See [FAQ: What's the process of a new go-starter release?](https://github.com/allaboutapps/go-starter/wiki/FAQ#whats-the-process-of-a-new-go-starter-release) +- You may now specify a **specific** tag/branch/commit from the upstream [go-starter](https://github.com/allaboutapps/go-starter) project while running `make git-fetch-go-starter`, `make git-compare-go-starter` and `make git-merge-go-starter`. This will especially come in handy if you want to do a multi-phased merge (for projects that haven't been updated in a long time): + - Merge with the latest: `make git-merge-go-starter` + - Merge with a specific tag, e.g. the tag [`go-starter-2021-10-19`](https://github.com/allaboutapps/go-starter/releases/tag/go-starter-2021-10-19): `GIT_GO_STARTER_TARGET=go-starter-2021-10-19 make git-merge-go-starter` + - Merge with a specific branch, e.g. the branch [`mr/housekeeping`](https://github.com/allaboutapps/go-starter/tree/mr/housekeeping): `GIT_GO_STARTER_TARGET=go-starter/mr/housekeeping make git-merge-go-starter` (heads up! it's `go-starter/`) + - Merge with a specific commit, e.g. the commit [`e85bedb94c3562602bc23d2bfd09fca3b13d1e02`](https://github.com/allaboutapps/go-starter/commit/e85bedb94c3562602bc23d2bfd09fca3b13d1e02): `GIT_GO_STARTER_TARGET=e85bedb94c3562602bc23d2bfd09fca3b13d1e02 make git-merge-go-starter` +- The primary GitHub Action pipeline `.github/workflows/build-test.yml` has been synced to include most validation tasks from our internal `.drone.yml` pipeline. Furthermore: + - Avoid `Build & Test` GitHub Action running twice (on `push` and on `pull_request`). + - Add trivy scan to our base Build & Test pipeline (as we know also build and test the `app` target docker image). + - Our GitHub Action pipeline will no longer attempt to cache the previously built Docker images by other pipelines, as extracting/restoring from cache (docker buildx) typically takes **longer** than fully rebuilding the whole image. We will reinvestigate caching mechanisms in the future if GitHub Actions provides a speedier and official integration for Docker images. + +## 2021-10-19 + +### Changed + +- **BREAKING** Upgrades to [Go 1.17.1](https://golang.org/doc/go1.17) `golang:1.17.1-buster` + - Switch to `//go:build ` from `// +build `. + - Migrates `go.mod` via `go mod tidy -go=1.17` (pruned module graphs). + - Do the following to upgrade: + 1. `make git-merge-go-starter` + 2. `./docker-helper --rebuild` + 3. Manually remove the new **second** `require` block (with all the `// indirect` modules) within your `go.mod` + 4. Execute `go mod tidy -go=1.17` once so the **second** `require` block appears again. + 5. Find `// +build ` and replace it with `//go:build `. + 6. `make all`. + 7. Recheck your `go.mod` that the newly added `// indirect` transitive dependencies are the proper version as you were previously using (e.g. via the output from `make get-licenses` and `make get-embedded-modules`). Feel free to move any `// indirect` tagged dependencies in your **first** `require` block to the **second** block. This is where they should live. +- **BREAKING** You now need to take special care when it comes to parsing **semicolons** (`;`) in **query strings** via `net/url` and `net/http` from Go >1.17! + - Anything before the semicolon will now be stripped. e.g. `example?a=1;b=2&c=3` would have returned `map[a:[1] b:[2] c:[3]]`, while now it returns `map[c:[3]]` + - See [Go 1.17 URL query parsing](https://golang.org/doc/go1.17#semicolons). + - You may need to manually migrate your handlers/tests regarding this new default handling. + +## 2021-09-27 + +### Changed + +- Added `make test-update-golden` for easily refreshing **all** golden files / snapshot tests (`y + ENTER` confirmation). +- Upgrades [golangci-lint](https://github.com/golangci/golangci-lint) from `v1.41.1` to [`v1.42.1`](https://github.com/golangci/golangci-lint/releases/tag/v1.42.1) (for reference [`v1.42.0`](https://github.com/golangci/golangci-lint/releases/tag/v1.42.0)). +- Bump github.com/go-openapi/strfmt from [0.20.1 to 0.20.2](https://github.com/go-openapi/strfmt/compare/v0.20.1...v0.20.2) +- Bump github.com/go-openapi/errors from [0.20.0 to 0.20.1](https://github.com/go-openapi/errors/compare/v0.20.0...v0.20.1) +- Bump github.com/go-openapi/runtime from [0.19.29 to 0.19.31](https://github.com/go-openapi/runtime/compare/v0.19.29...v0.19.31) +- Bump github.com/rs/zerolog from [1.23.0 to 1.25.0](https://github.com/rs/zerolog/compare/v1.23.0...v1.25.0) +- Bump google.golang.org/api from [0.52.0 to 0.57.0](https://github.com/allaboutapps/go-starter/pull/124) +- Bump github.com/lib/pq from [v1.10.2 to v1.10.3](https://github.com/lib/pq/releases/tag/v1.10.3) +- Bump github.com/spf13/viper from [1.8.1 to v1.9.0](https://github.com/spf13/viper/releases/tag/v1.9.0) +- Bump github.com/labstack/echo from [4.5.0 to v4.6.1](https://github.com/labstack/echo/compare/v4.5.0...v4.6.1) +- Update golang.org/x/crypto and golang.org/x/sys + +## 2021-08-17 + +### Changed + +- **Hotfix**: We will pin the `Dockerfile` development and builder stage to `golang:1.16.7-buster` (+ `-buster`) for now, as currently the [new debian bullseye release within the go official docker images](https://github.com/docker-library/golang/commit/48a7371ed6055a97a10adb0b75756192ad5f1c97) breaks some tooling. The upgrade to debian bullseye and Go 1.17 will happen ~simultaneously~ **separately** within go-starter in the following weeks. + +## 2021-08-16 + +### Changed + +- remove ioutil (https://golang.org/doc/go1.16#ioutil) + +## 2021-08-06 + +### Changed + +- Bump golang from 1.16.6 to [1.16.7](https://github.com/golang/go/issues?q=milestone%3AGo1.16.7+label%3ACherryPickApproved) (requires `./docker-helper.sh --rebuild`). +- Adds `util.GetEnvAsStringArrTrimmed` and minor `util` test coverage upgrades. + +## 2021-08-04 + +### Changed + +- `README.md` badges for go-starter. +- Fix some misspellings of English words within `internal/test/*.go` comments. +- Upgrades + - Bump `github.com/labstack/echo/v4` from 4.4.0 to [4.5.0](https://github.com/labstack/echo/blob/master/CHANGELOG.md#v450---2021-08-01): + - Switch from `github.com/dgrijalva/jwt-go` to [`github.com/golang-jwt/jwt`](https://github.com/golang-jwt/jwt) to mitigate [CVE-2020-26160](https://nvd.nist.gov/vuln/detail/CVE-2020-26160). + - Note that it might take some time until the former dep fully leaves our dependency graph, as it is also a transitive dependency of various versions of [`github.com/spf13/viper`](https://github.com/spf13/viper/issues/997). + - However, even though this functionality was never used by go-starter, this change fixes an important part: The original `github.com/dgrijalva/jwt-go` is no longer included in the **final `app` binary**, it is fully replaced by `github.com/golang-jwt/jwt`. + - Our `.trivyignore` still excludes [CVE-2020-26160](https://nvd.nist.gov/vuln/detail/CVE-2020-26160) as trivy cannot skip checking transitive dependencies. + - **Breaking**: If you have actually directly depended upon `github.com/dgrijalva/jwt-go`, please switch to `github.com/golang-jwt/jwt` via the following command: `find -type f -name "*.go" -exec sed -i "s/dgrijalva\/jwt-go/golang-jwt\/jwt/g" {} \;` + +## 2021-07-30 + +### Changed + +- Upgrades: + - Bump golang from 1.16.5 to [1.16.6](https://groups.google.com/g/golang-announce/c/n9FxMelZGAQ) + - Bump github.com/labstack/echo/v4 from 4.3.0 to [4.4.0](https://github.com/labstack/echo/blob/master/CHANGELOG.md) (adds `binder.BindHeaders` support, not affecting our goswagger `runtime.Validatable` bind helpers) + - Bump github.com/gabriel-vasile/mimetype from 1.3.0 to [1.3.1](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.3.1) + - Bump github.com/spf13/cobra from 1.1.3 to [1.2.1](https://github.com/spf13/cobra/releases/tag/v1.2.1) (and see all the big completion upgrades in [1.2.0](https://github.com/spf13/cobra/releases/tag/v1.2.0)) + - Bump google.golang.org/api from 0.49.0 to [0.52.0](https://github.com/allaboutapps/go-starter/pull/106) + - Bump gotestsum to [1.7.0](https://github.com/gotestyourself/gotestsum/releases/tag/v1.7.0) (adds handy keybindings while you are in `make watch-tests` mode, see [While in watch mode, pressing some keys will perform an action](https://github.com/gotestyourself/gotestsum#run-tests-when-a-file-is-saved)) + - Bump watchexec to [1.17.0](https://github.com/watchexec/watchexec/releases/tag/cli-v1.17.0) + - Bump golang.org/x/crypto to `v0.0.0-20210711020723-a769d52b0f97` + +## 2021-07-29 + +### Changed + +- Fixed `Makefile` has disregarded `pipefail`s in executed targets (e.g. `make sql-spec-migrate` previously returned exit code `0` even if there were migration errors as its output was piped internally). We now set `-cEeuo pipefail` for make's shell args, preventing these issues. + +## 2021-06-30 + +### Changed + +- **BREAKING** Switched from [`golint`](https://github.com/golang/lint) to [`revive`](https://github.com/mgechev/revive) + - [`golint` is deprecated](https://github.com/golang/go/issues/38968). + - [`revive`](https://github.com/mgechev/revive) is considered to be a drop-in replacement for `golint`, however this change still might lead to breaking changes in your codebase. +- **BREAKING** `make lint` no longer uses `--fast` when calling `golangci-lint` + - Up until now, `make lint` also ran `golangci-lint` using the `--fast` flag to remain consistent with the linting performed by VSCode automatically. + - As running only fast linters in both steps meant skipping quite a few validations (only 4/13 enabled linters are actually active), a decision has been made to break consistency between the two lint-steps and perform "full" linting during the build pipeline. + - This change could potentially bring up additional warnings and thus fail your build until fixed. +- **BREAKING** `gosec` is now also applied to test packages + - All linters are now applied to every source code file in this project, removing the previous exclusion of `gosec` from test files/packages + - As `gosec` might (incorrectly) detect some hardcoded credentials in your tests (variable names such as `passwordResetLink` get flagged), this change might require some fixes after merging. +- Extended auth middleware to allow for multiple auth token sources + - Default token validator uses access token table, maintaining previous behavior without any changes required. + - Token validator can be changed to e.g. use separate API keys for specific endpoints, allowing for more flexibility if so desired. +- Changed `util.LogFromContext` to always return a valid logger + - Helper no longer returns a disabled logger if context provided did not have an associated logger set (e.g. by middleware). If you still need to disable the logger for a certain context/function, use `util.DisableLogger(ctx, true)` to force-disable it. + - Added request ID to context in logger middleware. +- Extended DB query helpers + - Fixed TSQuery escaping, should now properly handle all type of user input. + - Implemented helper for JSONB queries (see `ExampleWhereJSON` for implementation details). + - Added `LeftOuterJoin` helper, similar to already existing `LeftJoin` variants. + - Managed transactions (via `WithTransaction`) can now have their options configured via `WithConfiguredTransaction`. + - Added util to combine query mods with `OR` expression. +- Implemented middleware for parsing `Cache-Control` header + - Allows for cache handling in relevant services, parsed directive is stored in request context. + - New middleware is enabled by default, can be disabled via env var (`SERVER_ECHO_ENABLE_CACHE_CONTROL_MIDDLEWARE`). +- Added extra misc. helpers + - Extra helpers for slice handling and generating random strings from a given character set have been included (`util.ContainsAllString`, `util.UniqueString`, `util.GenerateRandomString`). + - Added util to check whether current execution runs inside a test environment (`util.RunningInTest`). +- Test and snapshot util improvements + - Added `snapshoter.SaveU` as a shorthand for updating a single test + - Implemented `GenericArrayPayload` with respective request helpers for array payloads in tests + - Added VScode launch task for updating all snapshots in a single test file + +## 2021-06-29 + +### Changed + +- We now directly bake the `gsdev` cli "bridge" (it actually just runs `go run -tags scripts /app/scripts/main.go "$@"`) into the `development` stage of our `Dockerfile` and create it at `/usr/bin/gsdev` (requires `./docker-helper.sh --rebuild`). + - `gsdev` was previously symlinked to `/app/bin` from `/app/scripts/gsdev` (within the projects' workspace) and `chmod +x` via the `Makefile` during `init`. + - However this lead to problems with WSL2 VSCode related development setups (always dirty git workspaces as WSL2 tries to prevent `+x` flags). + - **BREAKING** encountered at **2021-06-30**: Upgrading your project via `make git-merge-go-starter` if you already have installed our previous `gsdev` approach from **2021-06-22** may require additional steps: + - It might be necessary to unlink the current `gsdev` symlink residing at `/app/bin/gsdev` before merging up (as this symlinked file will no longer exist)! + - Do this by issuing `rm -f /app/bin/gsdev` which will remove the symlink which pointed to the previous (now gone bash script) at `/app/scripts/gsdev`. + - It might also be handy to install the newer variant directly into your container (without requiring a image rebuild). Do this by: + - `sudo su` to become root in the container, + - issuing the following command: `printf '#!/bin/bash\nset -Eeo pipefail\ncd /app && go run -tags scripts ./scripts/main.go "$@"' > /usr/bin/gsdev && chmod 755 /usr/bin/gsdev` (in sync with what we do in our `Dockerfile`) and + - `[CTRL + c]` to return to being the `development` user within your container. + +## 2021-06-24 + +### Changed + +- Introduces GitHub Actions docker layer caching via docker buildx. For details see `.github/workflows/build-test.yml`. +- Upgrades: + - Bump golang from 1.16.4 to [1.16.5](https://groups.google.com/g/golang-announce/c/RgCMkAEQjSI/m/r_EP-NlKBgAJ) + - golangci-lint@[v1.41.1](https://github.com/golangci/golangci-lint/releases/tag/v1.41.1) + - Bump github.com/rs/zerolog from 1.22.0 to [1.23.0](https://github.com/allaboutapps/go-starter/pull/92) + - Bump github.com/go-openapi/runtime from 0.19.28 to 0.19.29 + - Bump github.com/volatiletech/sqlboiler/v4 from 4.5.0 to [4.6.0](https://github.com/volatiletech/sqlboiler/blob/HEAD/CHANGELOG.md#v460---2021-06-06) + - Bump github.com/rubenv/sql-migrate v0.0.0-20210408115534-a32ed26c37ea to v0.0.0-20210614095031-55d5740dbbcc + - Bump github.com/spf13/viper v1.7.1 to v1.8.0 + - Bump golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a to v0.0.0-20210616213533-5ff15b29337e + - Bump golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea to v0.0.0-20210616094352-59db8d763f22 + - Bump google.golang.org/api v0.47.0 to v0.49.0 +- Fixes linting within `/scripts/**/*.go`, now activated by default. + +## 2021-06-22 + +### Changed + +- Development scripts are no longer called via `go run [script]` but via `gsdev`: + - The `gsdev` cli is our new entrypoint for development workflow specific scripts, these scripts are not available in the final `app` binary. + - All previous `go run` scripts have been moved to their respective `/scripts/cmd` cli entrypoint + internal implementation within `/scripts/internal/**`. + - Please use `gsdev --help` to get an overview of available development specific commands. + - `gsdev` relys on a tiny helper bash script `scripts/gsdev` which gets symlinked to `/app/bin` on `make init`. + - Use `make test-scripts` to run tests regarding these internal scripts within `/scripts/**/*_test.go`. + - We now enforce that all `/scripts/**/*.go` files set the `// +build scripts` build tag. We do this to ensure these files are not directly depended upon from the actual `app` source-code within `/internal`. +- VSCode's `.devcontainer/devcontainer.json` now defines that the go tooling must use the `scripts` build tag for its IntelliSense. This is neccessary to still get proper code-completion when modifying resources at `/scripts/**/*.go`. You may need to reattach VSCode and/or run `./docker-helper.sh --rebuild`. + +### Added + +- Scaffolding tool to quickly generate generic CRUD endpoint stubs. Usage: `gsdev scaffold [resource name] [flags]`, also see `gsdev scaffold --help`. + +## 2021-05-26 + +### Changed + +- Scans for [CVE-2020-26160](https://nvd.nist.gov/vuln/detail/CVE-2020-26160) also match for our final `app` binary, however, we do not use `github.com/dgrijalva/jwt-go` as part of our auth logic. This dependency is mostly here because of child dependencies, that yet need to upgrade to `>=v4.0.0`. Therefore, we currently disable this CVE for scans in this project (via `.trivyignore`). +- Upgrades `Dockerfile`: [`watchexec@v1.16.1`](https://github.com/watchexec/watchexec/releases/tag/cli-v1.16.1), [`lichen@v0.1.4`](https://github.com/uw-labs/lichen/releases/tag/v0.1.4) (requires `./docker-helper.sh --rebuild`). + +## 2021-05-18 + +### Changed + +- Upgraded `Dockerfile` to `golang:1.16.4`, `gotestsum@v1.6.4`, `golangci-lint@v1.40.1`, `watchexec@v1.16.0` (requires `./docker-helper.sh --rebuild`). +- Upgraded `go.mod`: + - [github.com/labstack/echo/v4@v4.3.0](https://github.com/labstack/echo/releases/tag/v4.3.0) + - [github.com/lib/pq@v1.10.2](https://github.com/lib/pq/releases/tag/v1.10.2) + - [github.com/gabriel-vasile/mimetype@v1.3.0](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.3.0) + - `github.com/go-openapi/runtime@v0.19.28` + - [github.com/rs/zerolog@v1.22.0](https://github.com/rs/zerolog/releases/tag/v1.22.0) + - `github.com/rubenv/sql-migrate@v0.0.0-20210408115534-a32ed26c37ea` + - `golang.org/x/crypto@v0.0.0-20210513164829-c07d793c2f9a` + - `golang.org/x/sys@v0.0.0-20210514084401-e8d321eab015` + - [google.golang.org/api@v0.46.0](https://github.com/googleapis/google-api-go-client/releases/tag/v0.46.0) +- GitHub Actions: + - Pin to `actions/checkout@v2.3.4`. + - Remove unnecessary `git checkout HEAD^2` in CodeQL step (Code Scanning recommends analyzing the merge commit for best results). + - Limit trivy and codeQL actions to `push` against `master` and `pull_request` against `master` to overcome read-only access workflow errors. + +## 2021-04-27 + +### Added + +- Adds `test.WithTestDatabaseFromDump*`, `test.WithTestServerFromDump` methods for writing tests based on a database dump file that needs to be imported first: + - We dynamically setup IntegreSQL pools for all combinations passed through a `test.DatabaseDumpConfig{}` object: + - `DumpFile string` is required, absolute path to dump file + - `ApplyMigrations bool` optional, default `false`, automigrate after installing the dump + - `ApplyTestFixtures bool` optional, default `false`, import fixtures after (migrating) installing the dump + - `test.ApplyDump(ctx context.Context, t *testing.T, db *sql.DB, dumpFile string) error` may be used to apply a dump to an existing database connection. + - As we have dedicated IntegreSQL pools for each combination, testing performance should be on par with the default IntegreSQL database pool. +- Adds `test.WithTestDatabaseEmpty*` methods for writing tests based on an empty database (also a dedicated IntegreSQL pool). +- Adds context aware `test.WithTest*Context` methods reusing the provided `context.Context` (first arg). +- Adds `make sql-dump` command to easily create a dump of the local `development` database to `/app/dumps/development_YYYY-MM-DD-hh-mm-ss.sql` (.gitignored). + +### Changed + +- `test.ApplyMigrations(t *testing.T, db *sql.DB) (countMigrations int, err error)` is now public (e.g. for usage with `test.WithTestDatabaseEmpty*` or `test.WithTestDatabaseFromDump*`) +- `test.ApplyTestFixtures(ctx context.Context, t *testing.T, db *sql.DB) (countFixtures int, err error)` is now public (e.g. for usage with `test.WithTestDatabaseEmpty*` or `test.WithTestDatabaseFromDump*`) +- `internal/test/test_database_test.go` and `/app/internal/test/test_server_test.go` were massively refactored to allow for better extensibility later on (non breaking, all method signatures are backward-compatible). + +## 2021-04-12 + +### Added + +- Adds echo `NoCache` middleware: Use `middleware.NoCache()` and `middleware.NoCacheWithConfig(Skipper)` to explicitly force browsers to never cache calls to these handlers/groups. + +### Changed + +- `/swagger.yml` and `/-/*` now explicity set no-cache headers by default, forcing browsers to re-execute calls each and every time. +- Upgrade [watchexec@v1.15.0](https://github.com/watchexec/watchexec/releases/tag/1.15.0) (requires `./docker-helper.sh --rebuild`). + +## 2021-04-08 + +### Added + +- Live-Reload for our swagger-ui is now available out of the box: + - [allaboutapps/browser-sync](https://hub.docker.com/r/allaboutapps/browser-sync) acts as proxy at [localhost:8081](http://localhost:8081/). + - Requires `./docker-helper.sh --up`. + - Best used in combination with `make watch-swagger` (still refreshes `make all` or `make swagger` of course). + +### Changed + +- Upgrades to [swaggerapi/swagger-ui:v3.46.0](https://github.com/swagger-api/swagger-ui/tree/v3.46.0) from [swaggerapi/swagger-ui:v3.28.0](https://github.com/swagger-api/swagger-ui/compare/v3.28.0...v3.46.0) +- Upgrades to [github.com/labstack/echo@v4.2.2](https://github.com/labstack/echo/releases/tag/v4.2.2) +- `golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2` +- Upgrades to [google.golang.org/api@v0.44.0](https://github.com/googleapis/google-api-go-client/releases/tag/v0.44.0) + +## 2021-04-07 + +### Changed + +- Moved `/api/main.yml` to `/api/config/main.yml` to overcome path resolve issues (`../definitions`) with the VSCode [42crunch.vscode-openapi](https://github.com/42Crunch/vscode-openapi) extension (auto-included in our devContainer) and our go-swagger concat behaviour. +- Updated [api/README.md](https://github.com/allaboutapps/go-starter/blob/master/api/README.md) information about `/api/swagger.yml` generation logic and changed `make swagger-concat` accordingly + +## 2021-04-02 + +### Changed + +- Bump [golang from v1.16.2 to v1.16.3](https://github.com/golang/go/issues?q=milestone%3AGo1.16.3+label%3ACherryPickApproved) (requires `./docker-helper.sh --rebuild`). + +## 2021-04-01 + +### Changed + +- Bump golang.org/x/crypto@v0.0.0-20210322153248-0c34fe9e7dc2 +- Bump golang.org/x/sys@v0.0.0-20210331175145-43e1dd70ce54 +- Bump [github.com/go-openapi/swag@v0.19.15](https://github.com/allaboutapps/go-starter/pull/71) +- Bump [github.com/go-openapi/strfmt@v0.20.1](https://github.com/allaboutapps/go-starter/pull/70) + +## 2021-03-30 + +### Changed + +- Bump [github.com/gotestyourself/gotestsum@v1.6.3](https://github.com/gotestyourself/gotestsum/releases/tag/v1.6.3) (requires `./docker-helper.sh --rebuild`). + +## 2021-03-26 + +### Changed + +- Bump [golangci-lint@v1.39.0](https://github.com/golangci/golangci-lint/releases/tag/v1.39.0) (requires `./docker-helper.sh --rebuild`). + +## 2021-03-25 + +### Changed + +- Bump github.com/rs/zerolog from [1.20.0 to 1.21.0](https://github.com/allaboutapps/go-starter/pull/69) +- Bump google.golang.org/api from [0.42.0 to 0.43.0](https://github.com/allaboutapps/go-starter/pull/68) + +## 2021-03-24 + +### Changed + +- We no longer do explicit calls to `t.Parallel()` in our go-starter tests (except autogenerated code). For the reasons why see [FAQ: Should I use `t.Parallel()` in my tests?](https://github.com/allaboutapps/go-starter/wiki/FAQ#should-i-use-tparallel-in-my-tests). +- Switched to [github.com/uw-labs/lichen](https://github.com/uw-labs/lichen) for getting license information of embedded dependencies in our final `./bin/app` binary. +- The following make targets are no longer flagged as `(opt)` and thus move into the main `make help` target (use `make help-all` to see all targets): + - `make lint`: Runs golangci-lint and make check-\*. + - `make go-test-print-slowest`: Print slowest running tests (must be done after running tests). + - `make get-licenses`: Prints licenses of embedded modules in the compiled bin/app. + - `make get-embedded-modules`: Prints embedded modules in the compiled bin/app. + - `make clean`: Cleans ./tmp and ./api/tmp folder. + - `make get-module-name`: Prints current go module-name (pipeable). +- `make check-gen-dirs` now ignores `.DS_Store` within `/internal/models/**/*` and `/internal/types/**/*` and echo an errors detailing what happened. +- Upgrade to [`github.com/go-openapi/runtime@v0.19.27`](https://github.com/go-openapi/runtime/compare/v0.19.26...v0.19.27) + +## 2021-03-16 + +### Changed + +- `make all` no longer executes `make info` as part of its targets chain. + - It's very common to use `make all` multiple times per day during development and thats fine! However, the output of `make info` is typically ignored by our engineers (if they explicitly want this information, they use `make info`). So `make all` was just too spammy in it's previous form. + - `make info` does network calls and typically takes around 5sec to execute. This slowdown is not acceptable when running `make all`, especially if the information it provides isn't used anyways. + - Thus: Just trigger `make info` manually if you need the information of the `[spec DB]` structure, current `[handlers]` and `[go.mod]` information. Furthermore you may also visit `tmp/.info-db`, `tmp/.info-handlers` and `tmp/.info-go` after triggering `make info` as we store this information there after a run. + +## 2021-03-15 + +### Changed + +- Upgrades `go.mod`: + - [`github.com/volatiletech/sqlboiler/v4@v4.5.0`](https://github.com/volatiletech/sqlboiler/blob/master/CHANGELOG.md#v450---2021-03-14) + - [`github.com/rogpeppe/go-internal@v1.8.0`](https://github.com/rogpeppe/go-internal/releases/tag/v1.8.0) + - `golang.org/x/crypto@v0.0.0-20210314154223-e6e6c4f2bb5b` + - ~`golang.org/x/sys@v0.0.0-20210314195730-07df6a141424`~ + - `golang.org/x/sys@v0.0.0-20210315160823-c6e025ad8005` + - [`google.golang.org/api@v0.42.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.42.0) +- `make help` no longer reports `(opt)` flagged targets, use `make help-all` instead. +- `make tools` now executes `go install {}` in parallel +- `make info` now fetches information in parallel +- Seeding: Switch to `db|dbUtil.WithTransaction` instead of manually managing the db transaction. _Note_: We will enforce using `WithTransaction` instead of manually managing the life-cycle of db transactions through a custom linter in an upcoming change. It's way safer and manually managing db transactions only makes sense in very very special cases (where you will be able to opt-out via linter excludes). Also see [What's `WithTransaction`, shouldn't I use `db.BeginTx`, `db.Commit`, and `db.Rollback`?](https://github.com/allaboutapps/go-starter/wiki/FAQ#whats-withtransaction-shouldnt-i-use-dbbegintx-dbcommit-and-dbrollback). + +### Fixed + +- The correct implementation of `(util|scripts).GetProjectRootDir() string` now gets automatically selected based on the `scripts` build tag. + - We currently have 2 different `GetProjectRootDir()` implementations and each one is useful on its own: + - `util.GetProjectRootDir()` gets used while `app` or `go test` runs and resolves in the following way: use `PROJECT_ROOT_DIR` (if set), else default to the resolved path to the executable unless we can't resolve that, then **panic**! + - `scripts.GetProjectRootDir()` gets used while **generation time** (`make go-generate`) and resolves in the following way: use `PROJECT_ROOT_DIR` (if set), otherwise default to `/app` (baked, as we can assume we are in the `development` container). + - `/internal/util/(get_project_root_dir.go|get_project_root_dir_scripts.go)` is now introduced to automatically switch to the proper implementation based on the `// +build !scripts` or `// +build scripts` build tag, thus it's now consistent to import `util.GetProjectRootDir()`, especially while handler generation time (`make go-generate`). + +## 2021-03-12 + +### Changed + +- Upgrades to `golang@v1.16.2` (use `./docker-helper.sh --rebuild`). +- Silence resolve of `GO_MODULE_NAME` if `go` was not found in path (typically host env related). + +## 2021-03-11 + +### Added + +- `make build` (`make go-build`) now sets `internal/config.ModuleName`, `internal/config.Commit` and `internal/config.BuildDate` via `-ldflags`. + - `/-/version` (mgmt key auth) endpoint is now available, prints the same as `app -v`. + - `app -v` is now available and prints out buildDate and commit. Sample: + +```bash +app -v +allaboutapps.dev/aw/go-starter @ 19c4cdd0da151df432cd5ab33c35c8987b594cac (2021-03-11T15:42:27+00:00) +``` + +### Changed + +- Upgrades to `golang@v1.16.1` (use `./docker-helper.sh --rebuild`). +- Updates `google.golang.org/api@v0.41.0`, `github.com/gabriel-vasile/mimetype@v1.2.0` ([new supported formats](https://github.com/gabriel-vasile/mimetype/tree/v1.2.0)), `golang.org/x/sys` +- Removed `**/.git` from `.dockerignore` (`builder` stage) as we want the local git repo available while running `make go-build`. +- `app --help` now prominently includes the module name of the project. +- Prominently recommend `make force-module-name` after running `make git-merge-go-starter` to fix all import paths. + +## 2021-03-09 + +### Added + +- Introduces `CHANGELOG.md` + +### Changed + +- `make git-merge-go-starter` now uses `--allow-unrelated-histories` by default. + - `README.md` and FAQ now mention that it's recommended to execute `make git-merge-go-starter` during project setup (especially for single commit generated from template project project setups). + - See [FAQ: I want to compare or update my project/fork to the latest go-starter master.](https://github.com/allaboutapps/go-starter/wiki/FAQ#i-want-to-compare-or-update-my-projectfork-to-the-latest-go-starter-master) +- Various typos in `README.md` and `Makefile`. +- Upgrade to [`golangci-lint@v1.38.0`](https://github.com/golangci/golangci-lint/releases/tag/v1.38.0) + +## 2021-03-08 + +### Added + +- `allaboutapps/nullable` is now included by default. See [#58](https://github.com/allaboutapps/go-starter/pull/58), [FAQ: I need an optional Swagger payload property that is nullable!](https://github.com/allaboutapps/go-starter/wiki/FAQ#i-need-an-optional-swagger-payload-property-that-is-nullable) + +### Changed + +- Upgrade to [`labstack/echo@v4.2.1`](https://github.com/labstack/echo/releases/tag/v4.2.1), [`lib/pq@v1.10.0`](https://github.com/lib/pq/releases/tag/v1.10.0) + +## 2021-02-23 + +### Deprecated + +- `util.BindAndValidate` is now marked as deprecated as [`labstack/echo@v4.2.0`](https://github.com/labstack/echo/releases/tag/v4.2.0) exposes a more granular binding through its `DefaultBinder`. + +### Added + +- The more specialized variants `util.BindAndValidatePathAndQueryParams` and `util.BindAndValidateBody` are now available. See [`/internal/util/http.go`](https://github.com/allaboutapps/go-starter/blob/master/internal/util/http.go#L87). + +### Changed + +- `golang@v1.16.0` +- [`labstack/echo@v4.2.0`](https://github.com/labstack/echo/releases/tag/v4.2.0) + +## 2021-02-16 + +### Changed + +- Upgrades to [`pgFormatter@v5.0.0`](https://github.com/darold/pgFormatter/releases) + forces VSCode to use that version within the devcontainer through it's extension. + +## 2021-02-09 + +### Changed + +- `golang@v1.15.8`, `go-swagger@v0.26.1` + +## 2021-02-01 + +### Changed + +``` +- Dockerfile updates: + - golang@1.15.7 + - apt add icu-devtools (VSCode live sharing) + - gotestsum@1.6.1 + - golangci-lint@v1.36.0 + - goswagger@v0.26.0 +- go.mod: + - sqlboiler@4.4.0 + - swag@0.19.3 + - strfmt@0.20.0 + - testify@1.7.0 + - go-openapi/runtime@v0.19.26 + - go-openapi/swag@v0.19.13 + - go-openapi/validate@v0.20.1 + - jordan-wright/email + - rogpeppe/go-internal@v1.7.0 + - golang.org/x/crypto + - golang.org/x/sys + - google.golang.org/api@v0.38.0 +``` + +### Fixed + +- disabled goswagger generate server flag `--keep-spec-order` as relative resolution of its temporal created yml file is broken - see https://github.com/go-swagger/go-swagger/issues/2216 + +## 2020-11-04 + +### Added + +- `make watch-swagger` and `make watch-sql` + +### Changed + +- sqlboiler@4.3.0 + +## 2020-11-02 + +### Added + +- `make watch-tests`: Watches .go files and runs package tests on modifications. + +## 2020-09-30 + +### Added + +- `pprof` handlers, see [FAQ: I need to (remotely) pprof my running service!](https://github.com/allaboutapps/go-starter/wiki/FAQ#i-need-to-remotely-pprof-my-running-service) + +## 2020-09-24 + +### Added + +- `make git-merge-go-starter`, see [FAQ: I want to compare or update my project/fork to the latest go-starter master.](https://github.com/allaboutapps/go-starter/wiki/FAQ#i-want-to-compare-or-update-my-projectfork-to-the-latest-go-starter-master) + +## 2020-09-22 + +### Added + +- `app probe readiness` and `app probe liveness` sub-commands. +- `/-/ready` and `/-/healthy` handlers. + +## 2020-09-16 + +### Changed + +- Force VSCode to use our installed version of golang-cilint +- All `*.go` files in `/scripts` now use the build tag `scripts` so we can ensure they are not compiled into the final `app` binary. + +### Added + +- `go.not` file to ensure certain generation- / test-only dependencies don't end up in the final `app` binary. Automatically checked though `make` (sub-target `make check-embedded-modules-go-not`). + +## 2020-09-11 + +- Switch to `distroless` as final app stage, see [FAQ: Should I use distroless/base or debian:buster-slim in the Dockerfile app stage?](https://github.com/allaboutapps/go-starter/wiki/FAQ#should-i-use-distrolessbase-or-debianbuster-slim-in-the-dockerfile-app-stage) diff --git a/.changes-go-starter/25.01.0.md b/.changes-go-starter/25.01.0.md new file mode 100644 index 0000000..fdcd608 --- /dev/null +++ b/.changes-go-starter/25.01.0.md @@ -0,0 +1,31 @@ +## 25.01.0 - 2025-01-02 +### Added +* Migrate the changelog tracking from manual tracking to the changelog tracking tool [changie](https://github.com/miniscruff/changie) +### Changed +* Update to [golang:1.23.4-bookworm](https://hub.docker.com/layers/library/golang/1.23.4-bookworm/images/sha256-5c3223fcb23efeccf495739c9fd9bbfe76cee51caea90591860395057eab3113) (requires `./docker-helper.sh --rebuild`) +* [Bump github.com/golangci/golangci-lint from v1.59.0 to v1.62.2](https://github.com/golangci/golangci-lint/releases/tag/v1.62.2) +* `go.mod` updates: + - Minor: [Bump github.com/BurntSushi/toml from v1.3.2 to v1.4.0](https://github.com/BurntSushi/toml/releases/tag/v1.4.0) + - Patch: [Bump github.com/gabriel-vasile/mimetype from v1.4.3 to v1.4.7](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.4.7) + - Minor: [Bump github.com/go-openapi/errors from v0.21.0 to v0.22.0](https://github.com/go-openapi/errors/releases/tag/v0.22.0) + - Minor: [Bump github.com/go-openapi/runtime from v0.27.1 to v0.28.0](https://github.com/go-openapi/runtime/releases/tag/v0.28.0) + - Minor: [Bump github.com/go-openapi/strfmt from v0.22.0 to v0.23.0](https://github.com/go-openapi/strfmt/releases/tag/v0.23.0) + - Minor: [Bump github.com/go-openapi/swag from v0.22.9 to v0.23.0](https://github.com/go-openapi/swag/releases/tag/v0.23.0) + - Minor: [Bump github.com/go-openapi/validate from v0.22.6 to v0.24.0](https://github.com/go-openapi/validate/releases/tag/v0.24.0) + - Patch: [Bump github.com/labstack/echo/v4 from v4.11.4 to v4.13.3](https://github.com/labstack/echo/releases/tag/v4.13.3) + - Patch: [Bump github.com/nicksnyder/go-i18n/v2 from v2.4.0 to v2.4.1](https://github.com/nicksnyder/go-i18n/releases/tag/v2.4.1) + - Minor: [Bump github.com/rs/zerolog from v1.31.0 to v1.33.0](https://github.com/rs/zerolog/releases/tag/v1.33.0) + - Minor: [Bump github.com/rubenv/sql-migrate from v1.6.1 to v1.7.1](https://github.com/rubenv/sql-migrate/releases/tag/v1.7.1) + - Patch: [Bump github.com/spf13/cobra from v1.8.0 to v1.8.1](https://github.com/spf13/cobra/releases/tag/v1.8.1) + - Minor: [Bump github.com/spf13/viper from v1.18.2 to v1.19.0](https://github.com/spf13/viper/releases/tag/v1.19.0) + - Minor: [Bump github.com/stretchr/testify from v1.8.4 to v1.10.0](https://github.com/stretchr/testify/releases/tag/v1.10.0) + - Patch: [Bump github.com/volatiletech/sqlboiler/v4 from v4.16.1 to v4.17.1](https://github.com/volatiletech/sqlboiler/releases/tag/v4.17.1) + - Patch: [Bump github.com/volatiletech/strmangle from v0.0.6 to v0.0.8](https://github.com/volatiletech/strmangle/releases/tag/v0.0.8) + - Minor: [Bump golang.org/x/crypto from v0.18.0 to v0.31.0](https://github.com/golang/crypto/releases/tag/v0.31.0) + - Minor: [Bump golang.org/x/mod from v0.14.0 to v0.22.0](https://github.com/golang/mod/releases/tag/v0.22.0) + - Minor: [Bump golang.org/x/sys from v0.16.0 to v0.28.0](https://github.com/golang/sys/releases/tag/v0.28.0) + - Minor: [Bump golang.org/x/text from v0.14.0 to v0.21.0](https://github.com/golang/text/releases/tag/v0.21.0) + - Minor: [Bump google.golang.org/api from v0.161.0 to v0.214.0](https://github.com/googleapis/google-api-go-client/releases/tag/v0.214.0) + +### Removed +* Remove `util.MinInt` and `util.MaxInt`, use the built-in functions `min` and `max` instead. See https://go.dev/ref/spec#Min_and_max diff --git a/.changes-go-starter/25.01.1.md b/.changes-go-starter/25.01.1.md new file mode 100644 index 0000000..30b4430 --- /dev/null +++ b/.changes-go-starter/25.01.1.md @@ -0,0 +1,3 @@ +## 25.01.1 - 2025-01-03 +### Changed +* Bump [github.com/volatiletech/sqlboiler/v4 from v4.17.1 to v4.18.0](https://github.com/volatiletech/sqlboiler/releases/tag/v4.18.0) diff --git a/.changes-go-starter/25.01.2.md b/.changes-go-starter/25.01.2.md new file mode 100644 index 0000000..323b3fa --- /dev/null +++ b/.changes-go-starter/25.01.2.md @@ -0,0 +1,6 @@ +## 25.01.2 - 2025-01-17 +### Changed +* Refactor migration application logic to apply each missing migration individually and to print infos about the migration being applied +* Refactor the cobra cmd setup structure and the flag parsing logic using struct-based flags. +**BREAKING**: The restructuring breaks additionally created subcommands attached to the existing commands. See [Migration Guide Command Restructuring](https://github.com/allaboutapps/go-starter/wiki/Migration-Guide-Command-Restructuring) + diff --git a/.changes-go-starter/25.01.3.md b/.changes-go-starter/25.01.3.md new file mode 100644 index 0000000..e986ac7 --- /dev/null +++ b/.changes-go-starter/25.01.3.md @@ -0,0 +1,4 @@ +## 25.01.3 - 2025-01-17 +### Fixed +* Fix shutdown handling to check all errors +* Fix nil pointer dereference of shutdown without s.Echo diff --git a/.changes-go-starter/25.02.0.md b/.changes-go-starter/25.02.0.md new file mode 100644 index 0000000..fa5534e --- /dev/null +++ b/.changes-go-starter/25.02.0.md @@ -0,0 +1,29 @@ +## 25.02.0 - 2025-02-17 +### Added +* Add STARTTLS support to the mailer, thx [@mwieser](https://github.com/mwieser) +* Extend util and test package with additional helper functions, thx [@mwieser](https://github.com/mwieser) +* Add MIME interface to use *mimtype.MIME or an already KnownMIME, thx [@mwieser](https://github.com/mwieser) +* Add function to detach context to avoid context cancelation. Can be used to pass context information to go routines without a deadline or cancel, thx [@mwieser](https://github.com/mwieser) +* Add oauth2 helper for PKCE extention to generate verifier and challenge, thx [@mwieser](https://github.com/mwieser) +* Extend mailer mock to support waiting for all expected mails to arrive to check asynchronously sent mails in tests, thx [@mwieser](https://github.com/mwieser) +* Add mock clock to server struct to ensure consistent and mockable time during tests +* Add devcontainer startup commands to import local gitconfig into the container +### Changed +* Separated the go-starter and child project readme and changelog files to prevent conflicts +* Simplify the endpoint tests by using test.RequireHTTPError, response snapshots and table-driven tests +* Update to [golang:1.24.0-bookworm](https://hub.docker.com/layers/library/golang/1.24.0-bookworm/images/sha256-b95002399f27188790f0a2b598bee84ae7bfbf1043fb60921da3b81928e303ba) (requires `./docker-helper.sh --rebuild`) +* Minor: [Bump github.com/golangci/golangci-lint from v1.62.2 to v1.64.5](https://github.com/golangci/golangci-lint/releases/tag/v1.64.5) +* Dependency updates: + - Patch: [Bump github.com/gabriel-vasile/mimetype from v1.4.7 to v1.4.8](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.4.8) + - Minor: [Bump github.com/spf13/cobra from v1.8.1 to v1.9.1](https://github.com/spf13/cobra/releases/tag/v1.9.1) + - Patch: [Bump github.com/volatiletech/sqlboiler/v4 from v4.17.1 to v4.18.0](https://github.com/volatiletech/sqlboiler/releases/tag/v4.18.0) + - Minor: [Bump golang.org/x/crypto from v0.31.0 to v0.33.0](https://github.com/golang/crypto/releases/tag/v0.33.0) + - Minor: [Bump golang.org/x/mod from v0.22.0 to v0.23.0](https://github.com/golang/mod/releases/tag/v0.23.0) + - Minor: [Bump golang.org/x/sys from v0.28.0 to v0.30.0](https://github.com/golang/sys/releases/tag/v0.30.0) + - Minor: [Bump golang.org/x/text from v0.21.0 to v0.22.0](https://github.com/golang/text/releases/tag/v0.22.0) + - Minor: [Bump google.golang.org/api from v0.214.0 to v0.221.0](https://github.com/googleapis/google-api-go-client/releases/tag/v0.221.0) + +### Deprecated +* Deprecated the mailer option UseTLS (`SERVER_SMTP_USE_TLS`) in favour of Encryption (`SERVER_SMTP_ENCRYPTION`). If you were using the `SERVER_SMTP_USE_TLS` flag before to enable TLS, you will need to migrate to the `SERVER_SMTP_ENCRYPTION` setting of `tls`. For the moment, both settings are supported (with a warning being printed when using `SERVER_SMTP_USE_TLS`, however support for the deprecated config will be dropped in one of the next releases. See [Mailer UseTLS SERVER_SMTP_USE_TLS Deprecation](https://github.com/allaboutapps/go-starter/wiki/Mailer-UseTLS-SERVER_SMTP_USE_TLS-Deprecation) +### Removed +* Remove `util.RunningInTest()` in favour of `testing.Testing()`, see https://pkg.go.dev/testing@master#Testing diff --git a/.changes-go-starter/25.02.1.md b/.changes-go-starter/25.02.1.md new file mode 100644 index 0000000..d7e35c4 --- /dev/null +++ b/.changes-go-starter/25.02.1.md @@ -0,0 +1,7 @@ +## 25.02.1 - 2025-02-17 +### Changed +* Improve the makefile help messages, thx [@rainchen](https://github.com/rainchen) +### Removed +* Remove `util.ContainsString`, use `slices.Contains` instead. Fixes https://github.com/allaboutapps/go-starter/issues/275. See https://go.dev/doc/go1.21#slices +### Fixed +* Fix Upsertable interface to match Upsert func of sqlboiler, fixes https://github.com/allaboutapps/go-starter/issues/277 diff --git a/.changes-go-starter/25.04.0.md b/.changes-go-starter/25.04.0.md new file mode 100644 index 0000000..ea5ba0b --- /dev/null +++ b/.changes-go-starter/25.04.0.md @@ -0,0 +1,35 @@ +## 25.04.0 - 2025-04-04 +### Added +* Add helper for streaming a file with a correctly encoded content disposition header to allow utf-8 filenames +* Add setup for collecting and exposing prometheus metrics with custom metrics and sqlstats metrics. +* Add custom not found handler for echo to show a HTML page if text/html is requested +* Add endpoint to delete user account with all related data +### Changed +* Moved the data fixtures from `internal/data/` to a separate package in `internal/data/fixtures`. **BREAKING**: Existing changes to the fixtures need to be moved from `internal/data/` to `internal/data/fixtures` and imports need to be updated +* Move the application logic from the handlers to dedicated services. **BREAKING**: This might break existing applications that have custom changes to the handlers. The custom changes need to be reflected in the new services. +* Fix the naming of the put push token endpoint from post to put +* Moved the auth related parts from `internal/api/auth` to `internal/auth`. **BREAKING**: Changes to `authentication_result.go`, `context.go` and `scopes.go` need to be reflected in `internal/auth` instead of `internal/api/auth` and imports need to be updated +* Bump Postgres from `postgres:12.4-alpine` to `postgres:17.4-alpine`. See the official Postgres release notes for more details: + * https://www.postgresql.org/docs/release/13.0/ + * https://www.postgresql.org/docs/release/14.0/ + * https://www.postgresql.org/docs/release/15.0/ + * https://www.postgresql.org/docs/release/16.0/ + * https://www.postgresql.org/docs/release/17.0/ + +* Move the test fixtures from `internal/test/` to a separate package in `internal/test/fixtures`. **BREAKING**: Existing changes to the fixtures need to be moved from `internal/test/` to `internal/test/fixtures` and imports need to be updated +* Update to `golang:1.24.2-bookworm` (requires `./docker-helper.sh --rebuild`) +* Dependency updates: + - Patch: [Bump github.com/BurntSushi/toml from v1.4.0 to v1.5.0](https://github.com/BurntSushi/toml/releases/tag/v1.5.0) + - Patch: [Bump github.com/go-openapi/errors from v0.22.0 to v0.22.1](https://github.com/go-openapi/errors/releases/tag/v0.22.1) + - Patch: [Bump github.com/go-openapi/swag from v0.23.0 to v0.23.1](https://github.com/go-openapi/swag/releases/tag/v0.23.1) + - Patch: [Bump github.com/spf13/viper from v1.19.0 to v1.20.1](https://github.com/spf13/viper/releases/tag/v1.20.1) + - Patch: [Bump golang.org/x/sys from v0.30.0 to v0.31.0](https://github.com/golang/sys/releases/tag/v0.31.0) + - Minor: [Bump github.com/prometheus/client_golang from v1.20.5 to v1.21.1](https://github.com/prometheus/client_golang/releases/tag/v1.21.1) + - Minor: [Bump github.com/rs/zerolog from v1.33.0 to v1.34.0](https://github.com/rs/zerolog/releases/tag/v1.34.0) + - Minor: [Bump golang.org/x/crypto from v0.33.0 to v0.36.0](https://github.com/golang/crypto/releases/tag/v0.36.0) + - Minor: [Bump golang.org/x/mod from v0.23.0 to v0.24.0](https://github.com/golang/mod/releases/tag/v0.24.0) + - Minor: [Bump golang.org/x/text from v0.22.0 to v0.23.0](https://github.com/golang/text/releases/tag/v0.23.0) + - Minor: [Bump google.golang.org/api from v0.221.0 to v0.228.0](https://github.com/googleapis/google-api-go-client/releases/tag/v0.228.0) + +### Removed +* Remove deprecated endpoint GetPushTestRoute diff --git a/.changes-go-starter/25.04.1.md b/.changes-go-starter/25.04.1.md new file mode 100644 index 0000000..4813393 --- /dev/null +++ b/.changes-go-starter/25.04.1.md @@ -0,0 +1,3 @@ +## 25.04.1 - 2025-04-04 +### Changed +* Bump Github action workflow `actions/checkout` from `v2.3.4` to `v4` diff --git a/.changes-go-starter/25.10.0.md b/.changes-go-starter/25.10.0.md new file mode 100644 index 0000000..b803ad2 --- /dev/null +++ b/.changes-go-starter/25.10.0.md @@ -0,0 +1,34 @@ +## 25.10.0 - 2025-10-16 +### Added +* Add handling for registration with account confirmation +* Add swagger spec linting using spectral +* Add base setup for apple site association file and android assetlinks for handling universal links +* Add [wire](https://github.com/google/wire) for initing the `Server` using dependency injection. **BREAKING**: Changes to the init logic need to be migrated to `wire`, see [docs/server-initialization.md](docs/server-initialization.md) for details. +* Add helper to create subcommand group +* Add a test executing the datbase migrations up, down and up again +### Changed +* the GenHandlers script can accept trailing wildcards for suffix matching +* replaced the deprecated tenv linter with usetesting +* Refactor the public http error to use a enum for the error type. **BREAKING**: All existing errors need to be migrated to use the enum as error type. Therefore the error type keys need to be added to the Enum `PublicHTTPErrorType` and need to be used in the error definition. +* Update to `golang:1.24.4-bookworm` (requires `./docker-helper.sh --rebuild`) + - Patch: Bump [github.com/gotestyourself/gotestsum](https://github.com/gotestyourself/gotestsum/releases/tag/v1.12.2) from 1.12.0 to 1.12.2 + +* Bump [github.com/golangci/golangci-lint](https://golangci-lint.run/product/migration-guide) from v1.64.5 to v2.1.6 +* Update to [golang:1.25.0-bookworm](https://hub.docker.com/layers/library/golang/1.25.0-bookworm/images/sha256-89bd1d243f3a5a48c9acc03e5fb01e97e0ba4874c249a54e693dee430f9ef74e) (requires `./docker-helper.sh --rebuild`) +* Minor: [Bump github.com/golangci/golangci-lint from v2.1.6 to v2.4.0](https://github.com/golangci/golangci-lint/releases/tag/v2.4.0) +* Patch: [Bump github.com/gotestyourself/gotestsum from v1.12.2 to v1.12.3](https://github.com/gotestyourself/gotestsum/releases/tag/v1.12.3) +* Dependency updates: +- Package replacement: [Replaced github.com/volatiletech packages with github.com/aarondl equivalents and bumped to latest versions](https://github.com/aarondl) +- Patch: [Bump github.com/gabriel-vasile/mimetype from v1.4.8 to v1.4.9](https://github.com/gabriel-vasile/mimetype/releases/tag/v1.4.9) +- Patch: [Bump github.com/go-openapi/errors from v0.22.1 to v0.22.2](https://github.com/go-openapi/errors/releases/tag/v0.22.2) +- Patch: [Bump github.com/labstack/echo/v4 from v4.13.3 to v4.13.4](https://github.com/labstack/echo/releases/tag/v4.13.4) +- Minor: [Bump github.com/rubenv/sql-migrate from v1.7.1 to v1.8.0](https://github.com/rubenv/sql-migrate/releases/tag/v1.8.0) +- Minor: [Bump github.com/prometheus/client_golang from v1.21.1 to v1.23.0](https://github.com/prometheus/client_golang/releases/tag/v1.23.0) +- Minor: [Bump golang.org/x/crypto from v0.36.0 to v0.41.0](https://github.com/golang/crypto/releases/tag/v0.41.0) +- Minor: [Bump golang.org/x/mod from v0.24.0 to v0.26.0](https://github.com/golang/mod/releases/tag/v0.26.0) +- Minor: [Bump golang.org/x/sys from v0.31.0 to v0.35.0](https://github.com/golang/sys/releases/tag/v0.35.0) +- Minor: [Bump golang.org/x/text from v0.23.0 to v0.28.0](https://github.com/golang/text/releases/tag/v0.28.0) +- Minor: [Bump google.golang.org/api from v0.228.0 to v0.247.0](https://github.com/googleapis/google-api-go-client/releases/tag/v0.247.0) + +### Removed +* Remove the mailer option UseTLS (`SERVER_SMTP_USE_TLS`) in favour of Encryption (`SERVER_SMTP_ENCRYPTION`). **BREAKING**: If you were using the `SERVER_SMTP_USE_TLS` flag before to enable TLS, you will need to migrate to the `SERVER_SMTP_ENCRYPTION` setting of `tls`. See [Mailer UseTLS SERVER_SMTP_USE_TLS Deprecation](https://github.com/allaboutapps/go-starter/wiki/Mailer-UseTLS-SERVER_SMTP_USE_TLS-Deprecation) diff --git a/.changes-go-starter/header.tpl.md b/.changes-go-starter/header.tpl.md new file mode 100644 index 0000000..1e0fc7f --- /dev/null +++ b/.changes-go-starter/header.tpl.md @@ -0,0 +1,9 @@ +# Changelog + +- All notable changes to this project will be documented in this file. +- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +- We do not follow [semantic versioning](https://semver.org/). +- All changes are solely **tracked by date** and have a **git tag** available (from 2021-10-19 onwards): + - Git tags are formatted like `go-starter-YYYY-MM-DD`. See [GitHub tags](https://github.com/allaboutapps/go-starter/tags) for all available go-starter git tags. + - The latest `master` is considered **stable** and should be periodically merged into our customer projects. +- Please follow the update process in *[I just want to update / upgrade my project!](https://github.com/allaboutapps/go-starter/wiki/FAQ#i-just-want-to-update--upgrade-my-project)*. \ No newline at end of file diff --git a/.changes-go-starter/prerelease/.gitkeep b/.changes-go-starter/prerelease/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.changes-go-starter/unreleased/.gitkeep b/.changes-go-starter/unreleased/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.changie-go-starter.yaml b/.changie-go-starter.yaml new file mode 100644 index 0000000..740eb64 --- /dev/null +++ b/.changie-go-starter.yaml @@ -0,0 +1,20 @@ +changesDir: .changes-go-starter +unreleasedDir: unreleased +headerPath: header.tpl.md +changelogPath: CHANGELOG-go-starter.md +versionExt: md +versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' +kindFormat: '### {{.Kind}}' +changeFormat: '* {{.Body}}' +kinds: + - label: Added + - label: Changed + - label: Deprecated + - label: Removed + - label: Fixed + - label: Security +newlines: + afterChangelogHeader: 1 + beforeChangelogVersion: 1 + endOfVersion: 1 +envPrefix: CHANGIE_ \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..eebf14c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,114 @@ +// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.106.0/containers/docker-existing-docker-compose +// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml. +{ + "name": "app", + // Update the 'dockerComposeFile' list if you have more compose files or use different names. + // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. + "dockerComposeFile": [ + "../docker-compose.yml" + ], + // The 'service' property is the name of the service for the container that VS Code should + // use. Update this value and .devcontainer/docker-compose.yml to the real service name. + "service": "service", + // The optional 'workspaceFolder' property is the path VS Code should open by default when + // connected. This is typically a file mount in .devcontainer/docker-compose.yml + "workspaceFolder": "/app", + // All containers should stop if we close / reload the VSCode window. + "shutdownAction": "stopCompose", + "customizations": { + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + // https://github.com/golang/tools/blob/master/gopls/doc/vscode.md#vscode + "go.useLanguageServer": true, + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + }, + // Optional: Disable snippets, as they conflict with completion ranking. + "editor.snippetSuggestions": "none" + }, + "[go.mod]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + "[sql]": { + "editor.formatOnSave": true + }, + // There are handly utility scripts within /scripts that we invoke via go run. + // These scripts (and its dependencies) should never be consumed by the actual server directly + // Thus they are flagged to require the "scripts" build tag. + // We only inform gopls and the vscode go compiler here, that it has to set this build tag if it sees such a file. + "go.buildTags": "scripts", + "gopls": { + // Add parameter placeholders when completing a function. + "usePlaceholders": true, + // If true, enable additional analyses with staticcheck. + // Warning: This will significantly increase memory usage. + // DISABLED, done via + "staticcheck": false + }, + // https://code.visualstudio.com/docs/languages/go#_intellisense + "go.autocompleteUnimportedPackages": true, + // https://github.com/golangci/golangci-lint#editor-integration + "go.lintTool": "golangci-lint", + "go.lintFlags": [ + "--fast", + "--timeout", + "5m" + ], + // disable test caching, race and show coverage (in sync with makefile) + "go.testFlags": [ + "-cover", + "-race", + "-count=1", + "-v" + ], + "go.coverMode": "atomic", // atomic is required when utilizing -race + "go.delveConfig": { + "dlvLoadConfig": { + // increase max length of strings displayed in debugger + "maxStringLen": 2048 + }, + "apiVersion": 2 + }, + // ensure that the pgFormatter VSCode extension uses the pgFormatter that comes preinstalled in the Dockerfile + "pgFormatter.pgFormatterPath": "/usr/local/bin/pg_format", + // only validate the merged swagger file as the individual files do not meet all linting requirements + "spectral.validateFiles": [ + "**/api/swagger.yml" + ] + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + // required: + "golang.go", + "bradymholt.pgformatter", + // optional: + "42crunch.vscode-openapi", + "stoplight.spectral", + "heaths.vscode-guid", + "bungcip.better-toml", + "eamodio.gitlens", + "casualjim.gotemplate", + "yzhang.markdown-all-in-one" + ] + } + }, + // import host-local git config, with all applicable includes, into the container (https://github.com/microsoft/vscode-remote-release/issues/2084#issuecomment-2259986798) + "initializeCommand": "git config -l --global --include > \"${localWorkspaceFolder}\"/.gitconfig.global", + "postAttachCommand": "while IFS='=' read -r key value; do git config --global \"$key\" \"$value\"; done < \"${containerWorkspaceFolder}\"/.gitconfig.global; rm -f \"${containerWorkspaceFolder}\"/.gitconfig.global", + // Uncomment the next line if you want start specific services in your Docker Compose config. + // "runServices": [], + // Uncomment the next line if you want to keep your containers running after VS Code shuts down. + // "shutdownAction": "none", + // Uncomment the next line to run commands after the container is created - for example installing git. + "postCreateCommand": "go version" + // "postCreateCommand": "apt-get update && apt-get install -y git", + // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. + // "remoteUser": "" +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4728ccc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.devcontainer +.vscode +.pkg +.tools-versions +Dockerfile +docker-compose.* +docker-helper.sh \ No newline at end of file diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..1afd9bb --- /dev/null +++ b/.drone.yml @@ -0,0 +1,453 @@ +# ----------------------------------------------------------------------------- +# SETTINGS +# ----------------------------------------------------------------------------- + +# Drone matrix: Additional ENV vars for substitution - http://docs.drone.io/matrix-builds/ +# Will be evaluated BEFORE the YAML is parsed, ONLY strings allowed, NO substitutions ${XXX} here. + +matrix: + include: + - BUILD_ENV: all + # The name of the k8s namespaces that these pipelines will target. + # K8S_DEPLOY_NS_DEV: + # K8S_DEPLOY_NS_STAGING: + # K8S_DEPLOY_NS_PRODUCTION: + +# YAML Configuration anchors - https://learnxinyminutes.com/docs/yaml/ +# Will be evaluated WHILE the YAML is parsed, any valid yaml allowed, substitutions ${XXX} allowed. + +alias: + # The image will be tagged with this, pushed to gcr and referenced with this key in the k8s deployment + - &IMAGE_DEPLOY_TAG ${DRONE_COMMIT_SHA} + + # The image name, defaults to lowercase repo name /, e.g. aw/aaa-cab-kubernetes-test + - &IMAGE_DEPLOY_NAME ${DRONE_REPO,,} + + # The intermediate builder image name + - &IMAGE_BUILDER_ID ${DRONE_REPO,,}-builder:${DRONE_COMMIT_SHA} + + # The full uniquely tagged app image name + - &IMAGE_DEPLOY_ID ${DRONE_REPO,,}:${DRONE_COMMIT_SHA} + + # # Defines which branches will trigger a docker image push our Google Cloud Registry (tags are always published) + # - &GCR_PUBLISH_BRANCHES [dev, staging, master] + + # # Docker registry publish default settings + # - &GCR_REGISTRY_SETTINGS + # image: plugins/gcr + # repo: a3cloud-192413/${DRONE_REPO,,} + # registry: eu.gcr.io + # secrets: + # - source: AAA_GCR_SERVICE_ACCOUNT_JSON + # target: google_credentials + # # local short-time-cache: don't cleanup any image layers after pushing + # purge: false + # # force compress of docker build context + # compress: true + # volumes: # mount needed to push the already build container + # - /var/run/docker.sock:/var/run/docker.sock + + # # Deployment default settings + # - &K8S_DEPLOY_SETTINGS + # image: eu.gcr.io/a3cloud-192413/aw/aaa-drone-kubernetes:latest + # pull: true + # secrets: + # - source: AAA_K8S_SERVER + # target: KUBERNETES_SERVER + # - source: AAA_K8S_SERVICE_ACCOUNT_CRT + # target: KUBERNETES_CERT + # - source: AAA_K8S_SERVICE_ACCOUNT_TOKEN + # target: KUBERNETES_TOKEN + # - source: AAA_GCR_SERVICE_ACCOUNT_JSON + # target: GCR_SERVICE_ACCOUNT + # deployment: app + # repo: eu.gcr.io/a3cloud-192413/${DRONE_REPO,,} + # container: [app] + # tag: *IMAGE_DEPLOY_TAG + # gcr_service_account_email: drone-ci-a3cloud@a3cloud-192413.iam.gserviceaccount.com + # mgmt_repo: https://github.com/allaboutapps/a3cloud-mgmt.git + # mgmt_git_email: infrastructure+drone@allaboutapps.at + + # ENV variables for executing within the test env (similar to the env in docker-compose.yml) + - &TEST_ENV + CI: ${CI} + + # required: env for main working database, service + # default for sql-migrate (target development) and psql cli tool + PGDATABASE: &PGDATABASE "development" + PGUSER: &PGUSER "dbuser" + PGPASSWORD: &PGPASSWORD "dbpass" + PGHOST: &PGHOST "postgres" + PGPORT: &PGPORT "5432" + PGSSLMODE: &PGSSLMODE "disable" + + # optional: env for sql-boiler (ability to generate models out of a "spec" database) + # sql-boiler should operate on a "spec" database only + PSQL_DBNAME: "spec" + PSQL_USER: *PGUSER + PSQL_PASS: *PGPASSWORD + PSQL_HOST: *PGHOST + PSQL_PORT: *PGPORT + PSQL_SSLMODE: *PGSSLMODE + + # required for drone: project root directory, used for relative path resolution (e.g. fixtures) + PROJECT_ROOT_DIR: /app + + # docker run related. + SERVER_MANAGEMENT_SECRET: "mgmt-secret" + + # Which build events should trigger the main pipeline (defaults to all) + - &BUILD_EVENTS [push, tag] + + # Pipeline merge helper: only execute if build event received + - &WHEN_BUILD_EVENT + when: + event: *BUILD_EVENTS + +# The actual pipeline building our product +pipeline: + # --------------------------------------------------------------------------- + # BUILD + # --------------------------------------------------------------------------- + + "database connection": + group: build + image: postgres:17.4-alpine # should be the same version as used in .drone.yml, .github/workflows, Dockerfile and live + commands: + # wait for postgres service to become available + - | + until psql -U $PGUSER -d $PGDATABASE -h postgres \ + -c "SELECT 1;" >/dev/null 2>&1; do sleep 1; done + # query the database + - | + psql -U $PGUSER -d $PGDATABASE -h postgres \ + -c "SELECT name, setting FROM pg_settings;" + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "docker build (target builder)": + group: build + image: docker:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + IMAGE_TAG: *IMAGE_BUILDER_ID + commands: + - "docker build --target builder --compress -t $${IMAGE_TAG} ." + <<: *WHEN_BUILD_EVENT + + "docker build (target app)": + group: build-app + image: docker:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + IMAGE_TAG: *IMAGE_DEPLOY_ID + commands: + - "docker build --target app --compress -t $${IMAGE_TAG} ." + <<: *WHEN_BUILD_EVENT + + # --------------------------------------------------------------------------- + # CHECK + # --------------------------------------------------------------------------- + + "trivy scan": + group: pre-test + image: aquasec/trivy:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /server/drone/trivy-cache:/root/.cache/ + environment: + IMAGE_TAG: *IMAGE_DEPLOY_ID + commands: + # Print report + - "trivy image --exit-code 0 --no-progress $${IMAGE_TAG}" + # Fail on severity HIGH and CRITICAL + - "trivy image --exit-code 1 --severity HIGH,CRITICAL --no-progress --ignore-unfixed $${IMAGE_TAG}" + <<: *WHEN_BUILD_EVENT + + "build & diff": + group: pre-test + image: *IMAGE_BUILDER_ID + commands: + - cd $PROJECT_ROOT_DIR # reuse go build cache from Dockerfile builder stage + - make tidy + - make build + - /bin/cp -Rf $PROJECT_ROOT_DIR/* $DRONE_WORKSPACE # switch back to drone workspace ... + - cd $DRONE_WORKSPACE + - "git diff --exit-code" # ... for git diffing (otherwise not possible as .git is .dockerignored) + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "info": + group: test + image: *IMAGE_BUILDER_ID + commands: + - cd $PROJECT_ROOT_DIR # reuse go build cache from Dockerfile builder stage + - make info + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "test": + group: test + image: *IMAGE_BUILDER_ID + commands: + - cd $PROJECT_ROOT_DIR # reuse go build cache from Dockerfile builder stage + - make test + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "test-scripts (gsdev, go-starter only)": + group: test + image: *IMAGE_BUILDER_ID + commands: + - cd $PROJECT_ROOT_DIR # reuse go build cache from Dockerfile builder stage + - make test-scripts + environment: *TEST_ENV + when: + repo: allaboutapps/AW-go-starter + event: *BUILD_EVENTS + + "git-compare-go-starter": + group: test + image: *IMAGE_BUILDER_ID + commands: + - make git-compare-go-starter + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "swagger-codegen-cli": + group: test + # https://github.com/swagger-api/swagger-codegen/blob/master/modules/swagger-codegen-cli/Dockerfile + image: swaggerapi/swagger-codegen-cli + commands: + # run the main swagger.yml validation. + - "java -jar /opt/swagger-codegen-cli/swagger-codegen-cli.jar validate -i ./api/swagger.yml" + <<: *WHEN_BUILD_EVENT + + "binary: deps": + group: test + image: *IMAGE_BUILDER_ID + commands: + - cd $PROJECT_ROOT_DIR + - make get-embedded-modules-count + - make get-embedded-modules + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "binary: licenses": + group: test + image: *IMAGE_BUILDER_ID + commands: + - cd $PROJECT_ROOT_DIR + - make get-licenses + environment: *TEST_ENV + <<: *WHEN_BUILD_EVENT + + "docker run (target app)": + group: test + image: docker:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + <<: *TEST_ENV + IMAGE_TAG: *IMAGE_DEPLOY_ID + commands: + # Note: NO network related tests are possible here, dnd can just + # run sibling containers. We have no possibility to connect them + # into the drone user defined per build docker network! + # https://github.com/drone-plugins/drone-docker/issues/193 + # https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ + - (env | grep "^\S*=" | grep -v -e "DRONE=" -e "DRONE_" -e "CI_" -e "CI=" -e "HOME=" -e "HOSTNAME=" -e "SHELL=" -e "PWD=" -e "PATH=") > .hostenv + - cat .hostenv + - "docker run --env-file .hostenv $${IMAGE_TAG} help" + - "docker run --env-file .hostenv $${IMAGE_TAG} -v" + - "docker run --env-file .hostenv $${IMAGE_TAG} env" + <<: *WHEN_BUILD_EVENT + + # --------------------------------------------------------------------------- + # PUBLISH + # --------------------------------------------------------------------------- + + # # Built a allowed branch? Push to cloud registry + # "publish ${DRONE_BRANCH}_${DRONE_COMMIT_SHA:0:10}": + # group: publish + # <<: *GCR_REGISTRY_SETTINGS + # tags: + # - build_${DRONE_BUILD_NUMBER} + # - ${DRONE_BRANCH/\//-}_${DRONE_COMMIT_SHA:0:10} + # - *IMAGE_DEPLOY_TAG + # - latest + # - ${DRONE_BRANCH/\//-} + # - '${DRONE_COMMIT_SHA:0:10}' + # when: + # branch: *GCR_PUBLISH_BRANCHES + # event: *BUILD_EVENTS + + # # Built a tag? Push to cloud registry + # "publish tag_${DRONE_COMMIT_SHA:0:10}": + # group: publish + # <<: *GCR_REGISTRY_SETTINGS + # tags: + # - build_${DRONE_BUILD_NUMBER} + # - tag_${DRONE_COMMIT_SHA:0:10} + # - *IMAGE_DEPLOY_TAG + # - latest + # - ${DRONE_TAG} + # - ${DRONE_COMMIT_SHA:0:10} + # when: + # event: tag + + # --------------------------------------------------------------------------- + # DEPLOYMENT + # --------------------------------------------------------------------------- + + # # autodeploy dev if it hits the branch + # "deploy ${DRONE_COMMIT_SHA:0:10} to ${K8S_DEPLOY_NS_DEV} (auto)": + # <<: *K8S_DEPLOY_SETTINGS + # namespace: ${K8S_DEPLOY_NS_DEV} + # mgmt_deployment_yaml: namespaces/${K8S_DEPLOY_NS_DEV}/app.deployment.yaml + # when: + # event: *BUILD_EVENTS + # branch: [dev] + + # # promote dev through "drone deploy dev" + # "deploy ${DRONE_COMMIT_SHA:0:10} to ${K8S_DEPLOY_NS_DEV} (promote)": + # <<: *K8S_DEPLOY_SETTINGS + # namespace: ${K8S_DEPLOY_NS_DEV} + # mgmt_deployment_yaml: namespaces/${K8S_DEPLOY_NS_DEV}/app.deployment.yaml + # when: + # environment: dev + # event: deployment + + # # autodeploy staging if it hits the branch + # "deploy ${DRONE_COMMIT_SHA:0:10} to ${K8S_DEPLOY_NS_STAGING} (auto)": + # <<: *K8S_DEPLOY_SETTINGS + # namespace: ${K8S_DEPLOY_NS_STAGING} + # mgmt_deployment_yaml: namespaces/${K8S_DEPLOY_NS_STAGING}/app.deployment.yaml + # when: + # event: *BUILD_EVENTS + # branch: [staging] + + # # promote staging through "drone deploy staging" + # "deploy ${DRONE_COMMIT_SHA:0:10} to ${K8S_DEPLOY_NS_STAGING} (promote)": + # <<: *K8S_DEPLOY_SETTINGS + # namespace: ${K8S_DEPLOY_NS_STAGING} + # mgmt_deployment_yaml: namespaces/${K8S_DEPLOY_NS_STAGING}/app.deployment.yaml + # when: + # environment: staging + # event: deployment + + # # promote production through "drone deploy production" + # "deploy ${DRONE_COMMIT_SHA:0:10} to ${K8S_DEPLOY_NS_PRODUCTION} (promote)": + # <<: *K8S_DEPLOY_SETTINGS + # namespace: ${K8S_DEPLOY_NS_PRODUCTION} + # mgmt_deployment_yaml: namespaces/${K8S_DEPLOY_NS_PRODUCTION}/app.deployment.yaml + # when: + # environment: production + # event: deployment + + # --------------------------------------------------------------------------- + # DEPLOYMENT go-starter + # Purpose: go-starter drone specific publish and deployment steps + # NOTE: you do not need to uncomment them for our customer projects + # These steps won't be executed unless we work in the main "allaboutapps/AW-go-starter" repo + # --------------------------------------------------------------------------- + + "go-starter publish ${DRONE_BRANCH}_${DRONE_COMMIT_SHA:0:10}": + group: go-starter-publish + image: plugins/gcr + repo: a3cloud-192413/${DRONE_REPO,,} + registry: eu.gcr.io + secrets: + - source: AAA_GCR_SERVICE_ACCOUNT_JSON + target: google_credentials + # local short-time-cache: don't cleanup any image layers after pushing + purge: false + # force compress of docker build context + compress: true + volumes: # mount needed to push the already build container + - /var/run/docker.sock:/var/run/docker.sock + tags: + - build_${DRONE_BUILD_NUMBER} + - ${DRONE_BRANCH/\//-}_${DRONE_COMMIT_SHA:0:10} + - *IMAGE_DEPLOY_TAG + - latest + - ${DRONE_BRANCH/\//-} + - "${DRONE_COMMIT_SHA:0:10}" + when: + repo: allaboutapps/AW-go-starter + branch: [master] + event: [push, tag] + + "go-starter deploy ${DRONE_COMMIT_SHA:0:10} to allaboutapps-go-starter-dev (auto)": + group: go-starter-deploy + image: eu.gcr.io/a3cloud-192413/aw/aaa-drone-kubernetes:latest + pull: true + secrets: + - source: AAA_K8S_SERVER + target: KUBERNETES_SERVER + - source: AAA_K8S_SERVICE_ACCOUNT_CRT + target: KUBERNETES_CERT + - source: AAA_K8S_SERVICE_ACCOUNT_TOKEN + target: KUBERNETES_TOKEN + - source: AAA_GCR_SERVICE_ACCOUNT_JSON + target: GCR_SERVICE_ACCOUNT + deployment: app + repo: eu.gcr.io/a3cloud-192413/${DRONE_REPO,,} + container: [app] + tag: *IMAGE_DEPLOY_TAG + gcr_service_account_email: drone-ci-a3cloud@a3cloud-192413.iam.gserviceaccount.com + mgmt_repo: https://github.com/allaboutapps/a3cloud-mgmt.git + mgmt_git_email: infrastructure+drone@allaboutapps.at + namespace: allaboutapps-go-starter-dev + mgmt_deployment_yaml: namespaces/allaboutapps-go-starter-dev/app.deployment.yaml + skip_live_update: true + kubernetes_cluster: a3cloud-dev + # pre-sync migration job + job: pre-sync-migration + mgmt_job_yaml: namespaces/allaboutapps-go-starter-dev/application.yaml + when: + repo: allaboutapps/AW-go-starter + branch: [master] + event: [push, tag] + +# Long living services where the startup order does not matter (otherwise use detach: true) +services: + # --------------------------------------------------------------------------- + # SERVICES + # --------------------------------------------------------------------------- + + "env": + image: alpine + commands: + - "env | sort" + + "postgres": + image: postgres:17.4-alpine # should be the same version as used in .drone.yml, .github/workflows, Dockerfile and live + environment: + POSTGRES_DB: *PGDATABASE + POSTGRES_USER: *PGUSER + POSTGRES_PASSWORD: *PGPASSWORD + # ATTENTION + # fsync=off, synchronous_commit=off and full_page_writes=off + # gives us a major speed up during local development and testing (~30%), + # however you should NEVER use these settings in PRODUCTION unless + # you want to have CORRUPTED data. + # DO NOT COPY/PASTE THIS BLINDLY. + # YOU HAVE BEEN WARNED. + # Apply some performance improvements to pg as these guarantees are not needed while running integration tests + command: "-c 'shared_buffers=128MB' -c 'fsync=off' -c 'synchronous_commit=off' -c 'full_page_writes=off' -c 'max_connections=100' -c 'client_min_messages=warning'" + <<: *WHEN_BUILD_EVENT + + "integresql": + image: ghcr.io/allaboutapps/integresql:v1.1.0 + environment: + PGHOST: *PGHOST + PGUSER: *PGUSER + PGPASSWORD: *PGPASSWORD + <<: *WHEN_BUILD_EVENT + + "mailhog": + image: mailhog/mailhog + <<: *WHEN_BUILD_EVENT diff --git a/.env.local.sample b/.env.local.sample new file mode 100644 index 0000000..9cea58d --- /dev/null +++ b/.env.local.sample @@ -0,0 +1,30 @@ +# This is a dotenv file. +# Syntax Overview: https://hexdocs.pm/dotenvy/dotenv-file-format.html +# Parser: github.com/subosito/gotenv + +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +# DO NOT USE IN PRODUCTION! +# DO NOT COMMIT INTO VERSION CONTROL! +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +# Why? +# Use .env.local to easily inject secrets into your server config. + +# When? +# Only use this locally, never in production. + +# What? +# .env.local.sample is only a "sample" and should never hold any secret values! +# .env.local is .gitignored. +# .env.local is automatically loaded when running app . +# .env.local is **not** automatically loaded when running go test / make test. +# .env.local ENV variables override OS ENV variables. +# .env.local is applied by /internal/config's DefaultServiceConfigFromEnv(). + +# How? +# Copy this file to `.env.local` to use it, e.g. with the following command: +# cp .env.local.sample .env.local + +# Be kind to your colleagues and show them which typical ENV **keys** they +# have to set (without setting the actual value within .env.local.sample): +SECRET_KEY= \ No newline at end of file diff --git a/.github/README.md b/.github/README.md new file mode 120000 index 0000000..7e9c45d --- /dev/null +++ b/.github/README.md @@ -0,0 +1 @@ +../README-go-starter.md \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5dbe531 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + # Enable version updates for gomod + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + + # Enable version updates for Docker + - package-ecosystem: "docker" + # Look for a `Dockerfile` in the `root` directory + directory: "/" + schedule: + interval: "daily" + + # Enable version updates for github-actions + - package-ecosystem: "github-actions" + # Workflow files stored in the + # default location of `.github/workflows` + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..3e527bf --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,102 @@ +name: Build & Test + +on: + push: + branches: "**" + # pull_request: + # branches: [master] + # types: [opened, reopened] # avoid running twice (on push above), see https://github.com/open-telemetry/opentelemetry-python/issues/1370 +env: + DOCKER_ENV_FILE: ".github/workflows/docker.env" +permissions: + security-events: write + actions: write + contents: read +jobs: + build-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.4-alpine # should be the same version as used in .drone.yml, .github/workflows, Dockerfile and live + env: + POSTGRES_DB: "development" + POSTGRES_USER: "dbuser" + POSTGRES_PASSWORD: "dbpass" + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + integresql: + image: ghcr.io/allaboutapps/integresql:v1.1.0 + env: + PGHOST: "postgres" + PGUSER: "dbuser" + PGPASSWORD: "dbpass" + mailhog: + image: mailhog/mailhog + steps: + - uses: actions/checkout@v4 + - name: docker build (target builder) + run: DOCKER_BUILDKIT=1 docker build --target builder --file Dockerfile --tag allaboutapps.dev/aw/go-starter:builder-${GITHUB_SHA} . + - name: docker build (target app) + run: DOCKER_BUILDKIT=1 docker build --target app --file Dockerfile --tag allaboutapps.dev/aw/go-starter:app-${GITHUB_SHA} . + - name: trivy scan + uses: aquasecurity/trivy-action@master + with: + image-ref: 'allaboutapps.dev/aw/go-starter:app-${{ github.sha }}' + format: 'template' + template: '@/contrib/sarif.tpl' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + - name: docker run (target builder) + run: docker run -d --env-file $DOCKER_ENV_FILE --network "${{ job.services.postgres.network }}" --name=builder -it allaboutapps.dev/aw/go-starter:builder-${GITHUB_SHA} + - name: "build & diff" + # Note builder stage now includes .git, thus we rm it again to again diff with the original git workspace + run: | + docker exec builder make tidy + docker exec builder make build + docker cp builder:/app ./post-build && rm -rf ./post-build/.git && git -C post-build diff --exit-code + - name: test + run: docker exec builder make test + - name: upload coverage to codecov + run: docker cp builder:/tmp/coverage.out ./coverage.out && bash <(curl -s https://codecov.io/bash) + - name: test-scripts (gsdev, go-starter only) + if: ${{ github.repository == 'allaboutapps/go-starter' }} + run: docker exec builder make test-scripts + - name: info + run: docker exec builder make info + - name: "binary: deps" + run: docker exec builder bash -c 'make get-embedded-modules-count && make get-embedded-modules' + - name: "binary: licenses" + run: docker exec builder make get-licenses + - name: docker run (target app) + run: | + docker run --env-file $DOCKER_ENV_FILE --network "${{ job.services.postgres.network }}" allaboutapps.dev/aw/go-starter:app-${GITHUB_SHA} help + docker run --env-file $DOCKER_ENV_FILE --network "${{ job.services.postgres.network }}" allaboutapps.dev/aw/go-starter:app-${GITHUB_SHA} -v + docker run --env-file $DOCKER_ENV_FILE --network "${{ job.services.postgres.network }}" allaboutapps.dev/aw/go-starter:app-${GITHUB_SHA} env + - name: upload trivy scan results to GitHub security tab + # Currently limited to master because of the following: + # Workflows triggered by Dependabot on the "push" event run with read-only access. Uploading Code Scanning results requires write access. + # To use Code Scanning with Dependabot, please ensure you are using the "pull_request" event for this workflow and avoid triggering on the "push" event for Dependabot branches. + # See https://docs.github.com/en/code-security/secure-coding/configuring-code-scanning#scanning-on-push for more information on how to configure these events. + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' + - name: stop container + if: ${{ always() }} + run: docker stop builder + - name: remove container + if: ${{ always() }} + run: docker rm builder + swagger-codegen-cli: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: run the main swagger.yml validation + run: | + docker run --rm -v ${{ github.workspace }}:/local swaggerapi/swagger-codegen-cli validate -i /local/api/swagger.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..09495ef --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,75 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + # Currently limited to master because of the following: + # Workflows triggered by Dependabot on the "push" event run with read-only access. Uploading Code Scanning results requires write access. + # To use Code Scanning with Dependabot, please ensure you are using the "pull_request" event for this workflow and avoid triggering on the "push" event for Dependabot branches. + # See https://docs.github.com/en/code-security/secure-coding/configuring-code-scanning#scanning-on-push for more information on how to configure these events. + branches: [master] + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + - cron: '0 19 * * 5' + +permissions: + security-events: write + actions: write + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['go'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2.3.4 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/docker.env b/.github/workflows/docker.env new file mode 100644 index 0000000..d67e0e9 --- /dev/null +++ b/.github/workflows/docker.env @@ -0,0 +1,22 @@ +CI=true +GITHUB_ACTIONS=true +# required: env for main working database, service +# default for sql-migrate (target development) and psql cli tool +PGDATABASE=development +PGUSER=dbuser +PGPASSWORD=dbpass +PGHOST=postgres +PGPORT=5432 +PGSSLMODE=disable + +# optional: env for sql-boiler (ability to generate models out of a "spec" database) +# sql-boiler should operate on a "spec" database only +PSQL_DBNAME=spec +PSQL_USER=dbuser +PSQL_PASS=dbpass +PSQL_HOST=postgres +PSQL_PORT=5432 +PSQL_SSLMODE=disable + +# optional: project root directory, used for relative path resolution (e.g. fixtures) +PROJECT_ROOT_DIR=/app \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b67fe68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# based on https://github.com/github/gitignore/blob/master/Go.gitignore + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# GOBIN +bin + +# local go mod cache +.pkg + +# temporary files +tmp + +# mac specific +.DS_Store + +# directory for rw files (PV mounted into the container) +# /assets/mnt folder should stay gitignored! +assets/mnt/** +!assets/mnt/.gitkeep +dumps + +# go debug / delve +__debug_bin + +# we support overloading ENV vars by parsing a dotenv formatted file +# this file can be used for local testing (secrets!) but should never be commited into git +.env.local + +# the postAttachCommand should clean this up, ensure it's not commited anyway +.gitconfig.global \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..427251b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,141 @@ +version: "2" +run: + # also lint files within /scripts. Those have "//go:build scripts" set. + build-tags: + - scripts +linters: + enable: + - revive + - testpackage + - gosec + - usetesting + - errorlint + - asasalint + - asciicheck + - bidichk + - canonicalheader + - containedctx + - copyloopvar + - decorder + - dogsled + - dupl + - dupword + - durationcheck + - errchkjson + - errname + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - funcorder + - gocheckcompilerdirectives + - gochecknoinits + - gochecksumtype + - goconst + - gocritic + - gocyclo + - godox + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - grouper + - iface + - importas + - inamedparam + - interfacebloat + - ireturn + - makezero + - mirror + - misspell + - mnd + - nakedret + - nestif + - nilerr + - nilnesserr + - nilnil + - noctx + - nonamedreturns + - nosprintfhostport + - prealloc + - predeclared + - promlinter + - protogetter + - reassign + - recvcheck + - rowserrcheck + - sqlclosecheck + - tagalign + - testableexamples + - testifylint + - thelper + - tparallel + - unconvert + - unparam + - usestdlibvars + - varnamelen + - wastedassign + - whitespace + - wrapcheck + - zerologlint + exclusions: + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + settings: + wrapcheck: + ignore-package-globs: + - "*/internal/*" + varnamelen: + ignore-names: + - tt + - t + ignore-decls: + - c echo.Context + - s *api.Server + - u *url.URL + - i int + - n int + - n int32 + - tx boil.ContextExecutor + - exec boil.ContextExecutor + - db *sql.DB + - v runtime.Validatable + - r io.ReadCloser + - ok bool + - wg sync.WaitGroup + - d time.Time + - e *echo.Echo + - tx *sql.Tx + - re *regexp.Regexp + - to string + - f FixtureMap + - le *zerolog.Event + ireturn: + allow: + - anon + - error + - empty + - stdlib + - github.com/aarondl/sqlboiler/v4/queries/qm.QueryMod + - github.com/labstack/echo/v4.Context + - github.com/dropbox/godropbox/time2.Clock + godox: + keywords: + - "FIXME" + forbidigo: + forbid: + - pattern: "^(fmt\\.Print(|f|ln)|print|println)$" + - pattern: "boil\\.WithDebug" + mnd: + ignored-numbers: + - '0644' + - '0755' + ignored-files: + - internal/config/server_config.go + - fixtures.go + gocritic: + disabled-checks: + - regexpMust \ No newline at end of file diff --git a/.spectral.yml b/.spectral.yml new file mode 100644 index 0000000..9b7fb01 --- /dev/null +++ b/.spectral.yml @@ -0,0 +1,87 @@ +# --------------------------------------------------------------------------- +# Spectral OpenAPI Guidelines: https://docs.stoplight.io/docs/spectral/4dec24461f3af-open-api-rules +# --------------------------------------------------------------------------- +extends: ["spectral:oas"] + +# --------------------------------------------------------------------------- +# Custom overrides +# --------------------------------------------------------------------------- +overrides: +# types that are not referenced in the swagger spec +# but are referenced in code (e.g. because parameter enums are not generated as Golang types) + - files: + - "swagger.yml#/definitions/orderDir" + rules: + oas2-unused-definition: "off" + +# --------------------------------------------------------------------------- +# Custom rules +# --------------------------------------------------------------------------- +rules: + # --------------------------------------------------------------------------- + # Rules from the spectral:oas preset, that are not needed + # --------------------------------------------------------------------------- + operation-tag-defined: "off" + oas2-api-host: "off" + oas2-api-schemes: "off" + info-contact: "off" + operation-description: "off" + + # --------------------------------------------------------------------------- + # Custom AAA Rules + # --------------------------------------------------------------------------- + aaa-properties-id-casing: + description: Properties ending with 'ID' must be cased as 'Id' + severity: error + recommended: true + message: "{{property}} must end with 'Id' instead of 'ID'" + given: $.definitions..properties[*]~ + then: + function: pattern + functionOptions: + notMatch: "ID$" + + # --------------------------------------------------------------------------- + # Rules from the Adidas API Guidelines: https://github.com/adidas/api-guidelines/blob/master/.spectral.yml + # --------------------------------------------------------------------------- + adidas-path-parameters-camelCase-alphanumeric: + description: Path parameters MUST follow camelCase + severity: warn + recommended: true + message: "{{property}} path parameter is not camelCase: {{error}}" + given: $..parameters[?(@.in == 'path')].name + then: + function: pattern + functionOptions: + match: "^[a-z][a-zA-Z0-9]+$" + + adidas-definitions-camelCase-alphanumeric: + description: All YAML/JSON definitions MUST follow fields-camelCase and be ASCII alphanumeric characters or `_` or `$`. + severity: error + recommended: true + message: "{{property}} MUST follow camelCase and be ASCII alphanumeric characters or `_` or `$`." + given: $.definitions[*]~ + then: + function: pattern + functionOptions: + match: "/^[a-z$_]{1}[A-Z09$_]*/" + + adidas-properties-camelCase-alphanumeric: + description: All JSON Schema properties MUST follow fields-camelCase and be ASCII alphanumeric characters or _ or $. + severity: error + recommended: true + message: "{{property}} MUST follow camelCase and be ASCII alphanumeric characters or _ or $." + given: $.definitions..properties[*]~ + then: + function: pattern + functionOptions: + match: "/^[a-z$]{1}[A-Z09$]*/" + + adidas-request-GET-no-body: + description: "A 'GET' request MUST NOT accept a 'body` parameter" + severity: error + given: $.paths..get.parameters..in + then: + function: pattern + functionOptions: + notMatch: "/^body$/" \ No newline at end of file diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..248e023 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + "ms-azuretools.vscode-docker", + "ms-vscode-remote.remote-containers" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c5d2467 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,39 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch app server", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "env": {}, + "args": [ + "server" + ] + }, + { + "name": "Launch file", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + "env": {}, + "args": [] + }, + { + "name": "Launch file and update snapshots", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + "env": { + "TEST_UPDATE_GOLDEN": true + }, + "args": [] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a58e5bf --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "make", + "type": "shell", + "command": "make", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..45af14b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,276 @@ +### ----------------------- +# --- Stage: development +# --- Purpose: Local development environment +# --- https://hub.docker.com/_/golang +# --- https://github.com/microsoft/vscode-remote-try-go/blob/master/.devcontainer/Dockerfile +### ----------------------- +FROM golang:1.25.0-bookworm AS development + +# Avoid warnings by switching to noninteractive +ENV DEBIAN_FRONTEND=noninteractive + +# Our Makefile / env fully supports parallel job execution +ENV MAKEFLAGS "-j 8 --no-print-directory" + +# postgresql-support: Add the official postgres repo to install the matching postgresql-client tools of your stack +# https://wiki.postgresql.org/wiki/Apt +# run lsb_release -c inside the container to pick the proper repository flavor +# e.g. stretch=>stretch-pgdg, buster=>buster-pgdg, bullseye=>bullseye-pgdg, bookworm=>bookworm-pgdg +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main" \ + | tee /etc/apt/sources.list.d/pgdg.list \ + && wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | apt-key add - + +# Setup GPG key to install Trivy to locally scan for vulnerabilities +RUN mkdir -m 0755 -p /etc/apt/keyrings/ \ + && wget -O- https://aquasecurity.github.io/trivy-repo/deb/public.key | \ + gpg --dearmor | \ + tee /etc/apt/keyrings/trivy.gpg > /dev/null; \ + chmod 644 /etc/apt/keyrings/trivy.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb bookworm main" \ + | tee /etc/apt/sources.list.d/trivy.list + +# Install required system dependencies +RUN apt-get update \ + && apt-get install -y \ + # + # Mandadory minimal linux packages + # Installed at development stage and app stage + # Do not forget to add mandadory linux packages to the final app Dockerfile stage below! + # + # -- START MANDADORY -- + ca-certificates \ + # --- END MANDADORY --- + # + # Development specific packages + # Only installed at development stage and NOT available in the final Docker stage + # based upon + # https://github.com/microsoft/vscode-remote-try-go/blob/master/.devcontainer/Dockerfile + # https://raw.githubusercontent.com/microsoft/vscode-dev-containers/master/script-library/common-debian.sh + # + # icu-devtools: https://stackoverflow.com/questions/58736399/how-to-get-vscode-liveshare-extension-working-when-running-inside-vscode-remote + # graphviz: https://github.com/google/pprof#building-pprof + # -- START DEVELOPMENT -- + apt-utils \ + dialog \ + openssh-client \ + less \ + iproute2 \ + procps \ + lsb-release \ + locales \ + sudo \ + bash-completion \ + bsdmainutils \ + graphviz \ + xz-utils \ + postgresql-client-17 \ + icu-devtools \ + tmux \ + rsync \ + trivy \ + # --- END DEVELOPMENT --- + # + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# env/vscode support: LANG must be supported, requires installing the locale package first +# https://github.com/Microsoft/vscode/issues/58015 +# https://stackoverflow.com/questions/28405902/how-to-set-the-locale-inside-a-debian-ubuntu-docker-container +RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=en_US.UTF-8 + +ENV LANG en_US.UTF-8 + +# sql pgFormatter: Integrates with vscode-pgFormatter (we pin pgFormatter.pgFormatterPath for the extension to this version) +# requires perl to be installed +# https://github.com/bradymholt/vscode-pgFormatter/commits/master +# https://github.com/darold/pgFormatter/releases +RUN mkdir -p /tmp/pgFormatter \ + && cd /tmp/pgFormatter \ + && wget https://github.com/darold/pgFormatter/archive/v5.5.tar.gz \ + && tar xzf v5.5.tar.gz \ + && cd pgFormatter-5.5 \ + && perl Makefile.PL \ + && make && make install \ + && rm -rf /tmp/pgFormatter + +# go gotestsum: (this package should NOT be installed via go get) +# https://github.com/gotestyourself/gotestsum/releases +RUN mkdir -p /tmp/gotestsum \ + && cd /tmp/gotestsum \ + && ARCH="$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)" \ + && wget "https://github.com/gotestyourself/gotestsum/releases/download/v1.12.3/gotestsum_1.12.3_linux_${ARCH}.tar.gz" \ + && tar xzf "gotestsum_1.12.3_linux_${ARCH}.tar.gz" \ + && cp gotestsum /usr/local/bin/gotestsum \ + && rm -rf /tmp/gotestsum + +# go linting: (this package should NOT be installed via go get) +# https://github.com/golangci/golangci-lint#binary +# https://github.com/golangci/golangci-lint/releases +RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ + | sh -s -- -b $(go env GOPATH)/bin v2.4.0 + +# go swagger: (this package should NOT be installed via go get) +# https://github.com/go-swagger/go-swagger/releases +RUN ARCH="$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)" \ + && curl -o /usr/local/bin/swagger -L'#' \ + "https://github.com/go-swagger/go-swagger/releases/download/v0.29.0/swagger_linux_${ARCH}" \ + && chmod +x /usr/local/bin/swagger + +# lichen: go license util +# TODO: Install from static binary as soon as it becomes available. +# https://github.com/uw-labs/lichen/tags +RUN go install github.com/uw-labs/lichen@v0.1.7 + +# cobra-cli: cobra cmd scaffolding generator +# TODO: Install from static binary as soon as it becomes available. +# https://github.com/spf13/cobra-cli/releases +RUN go install github.com/spf13/cobra-cli@v1.3.0 + +# govulncheck: go vulnerability checker +RUN go install golang.org/x/vuln/cmd/govulncheck@latest + +# changie: a tool to help manage changelogs +RUN go install github.com/miniscruff/changie@v1.21.0 + +# watchexec +# https://github.com/watchexec/watchexec/releases +RUN mkdir -p /tmp/watchexec \ + && cd /tmp/watchexec \ + && wget https://github.com/watchexec/watchexec/releases/download/v1.25.1/watchexec-1.25.1-$(arch)-unknown-linux-musl.tar.xz \ + && tar xf watchexec-1.25.1-$(arch)-unknown-linux-musl.tar.xz \ + && cp watchexec-1.25.1-$(arch)-unknown-linux-musl/watchexec /usr/local/bin/watchexec \ + && rm -rf /tmp/watchexec + +# yq +# https://github.com/mikefarah/yq/releases +RUN mkdir -p /tmp/yq \ + && cd /tmp/yq \ + && ARCH="$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)" \ + && wget "https://github.com/mikefarah/yq/releases/download/v4.40.5/yq_linux_${ARCH}.tar.gz" \ + && tar xzf "yq_linux_${ARCH}.tar.gz" \ + && cp "yq_linux_${ARCH}" /usr/local/bin/yq \ + && rm -rf /tmp/yq + +# spectral: OpenAPI / JSON schema linter +# https://github.com/stoplightio/spectral +RUN curl -L https://raw.github.com/stoplightio/spectral/master/scripts/install.sh | sh + +# gsdev +# The sole purpose of the "gsdev" cli util is to provide a handy short command for the following (all args are passed): +# go run -tags scripts /app/scripts/main.go "$@" +RUN printf '#!/bin/bash\nset -Eeo pipefail\ncd /app && go run -tags scripts ./scripts/main.go "$@"' > /usr/bin/gsdev && chmod 755 /usr/bin/gsdev + +# linux permissions / vscode support: Add user to avoid linux file permission issues +# Detail: Inside the container, any mounted files/folders will have the exact same permissions +# as outside the container - including the owner user ID (UID) and group ID (GID). +# Because of this, your container user will either need to have the same UID or be in a group with the same GID. +# The actual name of the user / group does not matter. The first user on a machine typically gets a UID of 1000, +# so most containers use this as the ID of the user to try to avoid this problem. +# 2020-04: docker-compose does not support passing id -u / id -g as part of its config, therefore we assume uid 1000 +# https://code.visualstudio.com/docs/remote/containers-advanced#_adding-a-nonroot-user-to-your-dev-container +# https://code.visualstudio.com/docs/remote/containers-advanced#_creating-a-nonroot-user +ARG USERNAME=development +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +# vscode support: cached extensions install directory +# https://code.visualstudio.com/docs/remote/containers-advanced#_avoiding-extension-reinstalls-on-container-rebuild +RUN mkdir -p /home/$USERNAME/.vscode-server/extensions \ + /home/$USERNAME/.vscode-server-insiders/extensions \ + && chown -R $USERNAME \ + /home/$USERNAME/.vscode-server \ + /home/$USERNAME/.vscode-server-insiders + +# linux permissions / vscode support: chown $GOPATH so $USERNAME can directly work with it +# Note that this should be the final step after installing all build deps +RUN mkdir -p /$GOPATH/pkg && chown -R $USERNAME /$GOPATH + +# https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history +RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" \ + && mkdir /home/$USERNAME/commandhistory \ + && touch /home/$USERNAME/commandhistory/.bash_history \ + && chown -R $USERNAME /home/$USERNAME/commandhistory \ + && echo "$SNIPPET" >> "/home/$USERNAME/.bashrc" + +# $GOBIN is where our own compiled binaries will live and other go.mod / VSCode binaries will be installed. +# It should always come AFTER our other $PATH segments and should be earliest targeted in stage "builder", +# as /app/bin will the shadowed by a volume mount via docker-compose! +# E.g. "which golangci-lint" should report "/go/bin" not "/app/bin" (where VSCode will place it). +# https://github.com/go-modules-by-example/index/blob/master/010_tools/README.md#walk-through +WORKDIR /app +ENV GOBIN /app/bin +ENV PATH $PATH:$GOBIN + +### ----------------------- +# --- Stage: builder +# --- Purpose: Statically built binaries and CI environment +### ----------------------- + +FROM development AS builder +WORKDIR /app +COPY Makefile /app/Makefile +COPY --chmod=0755 rksh /app/rksh +COPY go.mod /app/go.mod +COPY go.sum /app/go.sum +RUN make modules +RUN make tools +COPY . /app/ +RUN make go-build + +### ----------------------- +# --- Stage: app +# --- Purpose: Image for actual deployment +# --- Prefer https://github.com/GoogleContainerTools/distroless over +# --- debian:buster-slim https://hub.docker.com/_/debian (if you need apt-get). +### ----------------------- + +# Distroless images are minimal and lack shell access. +# https://github.com/GoogleContainerTools/distroless/blob/master/base/README.md +# The :debug image provides a busybox shell to enter (base-debian10 only, not static). +# https://github.com/GoogleContainerTools/distroless#debug-images +FROM gcr.io/distroless/base-debian12:debug AS app + +# FROM debian:bookworm-slim AS app +# RUN apt-get update \ +# && apt-get install -y \ +# # +# # Mandadory minimal linux packages +# # Installed at development stage and app stage +# # Do not forget to add mandadory linux packages to the base development Dockerfile stage above! +# # +# # -- START MANDADORY -- +# ca-certificates \ +# # --- END MANDADORY --- +# # +# && apt-get clean \ +# && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/bin/app /app/ +COPY --from=builder /app/api/swagger.yml /app/api/ +COPY --from=builder /app/assets /app/assets/ +COPY --from=builder /app/migrations /app/migrations/ +COPY --from=builder /app/web /app/web/ + +WORKDIR /app + +# Must comply to vector form +# https://github.com/GoogleContainerTools/distroless#entrypoints +# Sample usage of this image: +# docker run help +# docker run db migrate +# docker run db seed +# docker run env +# docker run probe readiness +# docker run probe liveness +# docker run server +# docker run server --migrate +ENTRYPOINT ["/app/app"] +CMD ["server", "--migrate"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7daedd5 --- /dev/null +++ b/Makefile @@ -0,0 +1,494 @@ +### ----------------------- +# --- Building +### ----------------------- + +# first is default target when running "make" without args +build: ##- Default 'make' target: sql, swagger, go-generate-handlers, go-format, go-build and lint. + @$(MAKE) build-pre + @$(MAKE) go-format + @$(MAKE) go-build + @$(MAKE) lint + +# useful to ensure that everything gets resetuped from scratch +all: clean init ##- Runs all of our common make targets: clean, init, build and test. + @$(MAKE) build + @$(MAKE) test + @$(MAKE) trivy + +info: info-db info-handlers info-go ##- Prints info about spec db, handlers, and go.mod updates, module-name and current go version. + +info-db: ##- (opt) Prints info about spec db. + @echo "[spec DB]" > tmp/.info-db + @cat scripts/sql/info.sql | psql -q -d "${PSQL_DBNAME}" >> tmp/.info-db + @cat tmp/.info-db + +info-handlers: ##- (opt) Prints info about handlers. + @echo "[handlers]" > tmp/.info-handlers + @gsdev handlers check --print-all >> tmp/.info-handlers + @echo "" >> tmp/.info-handlers + @cat tmp/.info-handlers + +info-go: ##- (opt) Prints go.mod updates, module-name and current go version. + @echo "[go.mod]" > tmp/.info-go + @$(MAKE) get-go-outdated-modules >> tmp/.info-go + @$(MAKE) info-module-name >> tmp/.info-go + @go version >> tmp/.info-go + @cat tmp/.info-go + +lint: check-gen-dirs check-script-dir check-handlers check-embedded-modules-go-not go-lint ##- Runs golangci-lint and make check-*. + +# these recipies may execute in parallel +build-pre: sql swagger go-generate ##- (opt) Runs pre-build related targets (sql, swagger, go-generate-handlers, go-generate). + @$(MAKE) go-generate-handlers + +go-format: ##- (opt) Runs go format. + go fmt ./... + +go-build: ##- (opt) Runs go build. + go build -ldflags $(LDFLAGS) -o bin/app + +go-lint: ##- (opt) Runs golangci-lint. + golangci-lint run --timeout 5m + +go-generate-handlers: ##- (opt) Generates the internal/api/handlers/handlers.go binding. + gsdev handlers gen + +go-generate: ##- Runs go generate. + @go generate ./... + +check-handlers: ##- (opt) Checks if implemented handlers match their spec (path). + gsdev handlers check + +# https://golang.org/pkg/cmd/go/internal/generate/ +# To convey to humans and machine tools that code is generated, +# generated source should have a line that matches the following +# regular expression (in Go syntax): +# ^// Code generated .* DO NOT EDIT\.$ +check-gen-dirs: ##- (opt) Ensures internal/models|types only hold generated files. + @echo "make check-gen-dirs" + @find ./internal/types -name ".*" -prune -o -type f -print | xargs -L1 grep -L '// Code generated .* DO NOT EDIT\.' \ + || (echo "Error: Non generated file(s) in ./internal/types!" && exit 1) + @find ./internal/models -name ".*" -prune -o -type f -print | xargs -L1 grep -L '// Code generated .* DO NOT EDIT\.' \ + || (echo "Error: Non generated file(s) in ./internal/models!" && exit 1) + +check-script-dir: ##- (opt) Ensures all scripts/**/*.go files have the "//go:build scripts" build tag set. + @echo "make check-script-dir" + @find ./scripts -type f -name '*.go' | xargs -L1 grep -L '//go:build scripts' || (echo "Error: Found unset '//go:build scripts' in ./scripts/**/*.go!" && exit 1) + +# https://github.com/gotestyourself/gotestsum#format +# w/o cache https://github.com/golang/go/issues/24573 - see "go help testflag" +# note that these tests should not run verbose by default (e.g. use your IDE for this) +# TODO: add test shuffling/seeding when landed in go v1.15 (https://github.com/golang/go/issues/28592) +# tests by pkgname +test: ##- Run tests, output by package, print coverage. + @$(MAKE) go-test-by-pkg + @$(MAKE) go-test-print-coverage + +# tests by testname +test-by-name: ##- Run tests, output by testname, print coverage. + @$(MAKE) go-test-by-name + @$(MAKE) go-test-print-coverage + +test-update-golden: ##- Refreshes all golden files / snapshot tests by running tests, output by package. + @echo "Attempting to refresh all golden files / snapshot tests (TEST_UPDATE_GOLDEN=true)!" + @echo -n "Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] + @TEST_UPDATE_GOLDEN=true gotestsum --hide-summary=skipped -- -race -count=1 ./... + +# note that we explicitly don't want to use a -coverpkg=./... option, per pkg coverage take precedence +go-test-by-pkg: ##- (opt) Run tests, output by package. + gotestsum --format pkgname-and-test-fails --format-hide-empty-pkg --jsonfile /tmp/test.log -- -race -cover -count=1 -coverprofile=/tmp/coverage.out ./... + +go-test-by-name: ##- (opt) Run tests, output by testname. + gotestsum --format testname --jsonfile /tmp/test.log -- -race -cover -count=1 -coverprofile=/tmp/coverage.out ./... + +go-test-print-coverage: ##- (opt) Print overall test coverage (must be done after running tests). + @printf "coverage " + @go tool cover -func=/tmp/coverage.out | tail -n 1 | awk '{$$1=$$1;print}' + +go-test-print-slowest: ##- Print slowest running tests (must be done after running tests). + gotestsum tool slowest --jsonfile /tmp/test.log --threshold 2s + +# TODO: switch to "-m direct" after go 1.17 hits: https://github.com/golang/go/issues/40364 +get-go-outdated-modules: ##- (opt) Prints outdated (direct) go modules (from go.mod). + @((go list -u -m -f '{{if and .Update (not .Indirect)}}{{.}}{{end}}' all) 2>/dev/null | grep " ") || echo "go modules are up-to-date." + +watch-tests: ##- Watches *.go files and runs package tests on modifications. + gotestsum --format testname --watch -- -race -count=1 + +test-scripts: ##- (opt) Run scripts tests (gsdev), output by package, print coverage. + @$(MAKE) go-test-scripts-by-pkg + @printf "coverage " + @go tool cover -func=/tmp/coverage-scripts.out | tail -n 1 | awk '{$$1=$$1;print}' + +go-test-scripts-by-pkg: ##- (opt) Run scripts tests (gsdev), output by package. + gotestsum --format pkgname-and-test-fails --jsonfile /tmp/test.log -- $$(go list -tags scripts ./... | grep "${GO_MODULE_NAME}/scripts") -tags scripts -race -cover -count=1 -coverprofile=/tmp/coverage-scripts.out ./... + +### ----------------------- +# --- Initializing +### ----------------------- + +init: ##- Runs make modules, tools and tidy. + @$(MAKE) modules + @$(MAKE) tools + @$(MAKE) tidy + +# cache go modules (locally into .pkg) +modules: ##- (opt) Cache packages as specified in go.mod. + go mod download + +tools: ##- (opt) Install tools. + @go install tool + +tidy: ##- (opt) Tidy our go.sum file. + go mod tidy + +### ----------------------- +# --- SQL +### ----------------------- + +sql-reset: ##- Wizard to drop and create our development database. + @echo "DROP & CREATE database:" + @echo " PGHOST=${PGHOST} PGDATABASE=${PGDATABASE}" PGUSER=${PGUSER} + @echo -n "Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] + psql -d postgres -c 'DROP DATABASE IF EXISTS "${PGDATABASE}";' + psql -d postgres -c 'CREATE DATABASE "${PGDATABASE}" WITH OWNER ${PGUSER} TEMPLATE "template0";' + +sql-drop-all: ##- Wizard to drop ALL databases: spec, development and tracked by integresql. + @echo "DROP ALL:" + TO_DROP=$$(psql -qtz0 -d postgres -c "SELECT 'DROP DATABASE \"' || datname || '\";' FROM pg_database WHERE datistemplate = FALSE AND datname != 'postgres';") + @echo "$$TO_DROP" + @echo -n "Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] + @echo "Resetting integresql..." + curl --fail -X DELETE http://integresql:5000/api/v1/admin/templates + @echo "Drop databases..." + echo $$TO_DROP | psql -tz0 -d postgres + @echo "Done. Please run 'make sql-reset && make sql-spec-reset && make sql-spec-migrate' to reinitialize." + +# This step is only required to be executed when the "migrations" folder has changed! +sql: ##- Runs sql format, all sql related checks and finally generates internal/models/*.go. + @$(MAKE) sql-format + @$(MAKE) sql-regenerate + +sql-regenerate: ##- (opt) Runs sql related checks and finally generates internal/models/*.go. + @$(MAKE) sql-check-files + @$(MAKE) sql-spec-reset + @$(MAKE) sql-spec-migrate + @$(MAKE) sql-check-and-generate + +sql-check-and-generate: sql-check-structure sql-boiler ##- (opt) Runs make sql-check-structure and sql-boiler. + +sql-boiler: ##- (opt) Runs sql-boiler introspects the spec db to generate internal/models/*.go. + @echo "make sql-boiler" + sqlboiler psql + +sql-format: ##- (opt) Formats all *.sql files. + @echo "make sql-format" + @find ${PWD} -path "*/tmp/*" -prune -name ".*" -prune -o -type f -iname "*.sql" -print \ + | grep --invert "/app/dumps/" \ + | grep --invert "/app/test/" \ + | xargs -i pg_format --inplace {} + +sql-check-files: sql-check-syntax sql-check-migrations-unnecessary-null ##- (opt) Check syntax and unnecessary use of NULL keyword. + +# check syntax via the real database +# https://stackoverflow.com/questions/8271606/postgresql-syntax-check-without-running-the-query +sql-check-syntax: ##- (opt) Checks syntax of all *.sql files. + @echo "make sql-check-syntax" + @find ${PWD} -path "*/tmp/*" -prune -name ".*" -prune -path ./dumps -prune -false -o -type f -iname "*.sql" -print \ + | grep --invert "/app/dumps/" \ + | grep --invert "/app/test/" \ + | xargs -i sed '1s#^#DO $$SYNTAX_CHECK$$ BEGIN RETURN;#; $$aEND; $$SYNTAX_CHECK$$;' {} \ + | psql -d postgres --quiet -v ON_ERROR_STOP=1 + +sql-check-migrations-unnecessary-null: ##- (opt) Checks migrations/*.sql for unnecessary use of NULL keywords. + @echo "make sql-check-migrations-unnecessary-null" + @(grep -R "NULL" ./migrations/ | grep --invert "DEFAULT NULL" | grep --invert "NOT NULL" | grep --invert "WITH NULL" | grep --invert "NULL, " | grep --invert ", NULL" | grep --invert "RETURN NULL" | grep --invert "SET NULL") \ + && exit 1 || exit 0 + +sql-spec-reset: ##- (opt) Drop and creates our spec database. + @echo "make sql-spec-reset" + @psql --quiet -d postgres -c 'DROP DATABASE IF EXISTS "${PSQL_DBNAME}";' + @psql --quiet -d postgres -c 'CREATE DATABASE "${PSQL_DBNAME}" WITH OWNER ${PSQL_USER} TEMPLATE "template0";' + +sql-spec-migrate: ##- (opt) Applies migrations/*.sql to our spec database. + @echo "make sql-spec-migrate" + @sql-migrate up -env spec | xargs -i echo "[spec DB]" {} + +sql-check-structure: sql-check-structure-fk-missing-index sql-check-structure-default-zero-values ##- (opt) Runs make sql-check-structure-*. + +sql-check-structure-fk-missing-index: ##- (opt) Ensures spec database objects have FK-indices set. + @echo "make sql-check-structure-fk-missing-index" + @cat scripts/sql/fk_missing_index.sql | psql -qtz0 --no-align -d "${PSQL_DBNAME}" -v ON_ERROR_STOP=1 + +sql-check-structure-default-zero-values: ##- (opt) Ensures spec database objects default values match go zero values. + @echo "make sql-check-structure-default-zero-values" + @cat scripts/sql/default_zero_values.sql | psql -qtz0 --no-align -d "${PSQL_DBNAME}" -v ON_ERROR_STOP=1 + +dumpfile := /app/dumps/development_$(shell date '+%Y-%m-%d-%H-%M-%S').sql +sql-dump: ##- Dumps the development database to '/app/dumps/development_YYYY-MM-DD-hh-mm-ss.sql'. + @mkdir -p /app/dumps + @pg_dump development --format=p --clean --if-exists > $(dumpfile) + @echo "Dumped '$(dumpfile)'. Use 'cat $(dumpfile) | psql' to restore" + +watch-sql: ##- Watches *.sql files in /migrations and runs 'make sql-regenerate' on modifications. + @echo Watching /migrations. Use Ctrl-c to stop a run or exit. + watchexec -p -w migrations --exts sql $(MAKE) sql-regenerate + +### ----------------------- +# --- Swagger +### ----------------------- + +swagger: ##- Runs make swagger-concat and swagger-server. + @$(MAKE) swagger-concat + @$(MAKE) swagger-server + +# Any sibling elements of a $ref are ignored. This is because $ref works by replacing itself and everything on its level with the definition it is pointing at. +# https://swagger.io/docs/specification/using-ref/ +swagger-lint-ref-siblings: ##- (opt) Checks api/**/*.[yml|yaml] for invalid usage of $ref (no siblings). + @echo "make swagger-lint-ref-siblings" + @rm -f /tmp/swagger-lint-ref-siblings-errors.log && touch /tmp/swagger-lint-ref-siblings-errors.log + @find api -type f -name "*.yml" -o -name "*.yaml" \ + | { \ + while read ymlfile; \ + do \ + ref_siblings=$$(yq e '.. | select(has("$$ref") and length != 1)' $$ymlfile); \ + ([[ -z "$$ref_siblings" ]] \ + || (echo "Error: Found invalid \$$ref siblings within $$ymlfile:" \ + && (yq -P e '[.. | select(has("$$ref") and length != 1)]' $$ymlfile) \ + && (echo $$ymlfile >> /tmp/swagger-lint-ref-siblings-errors.log))); \ + done \ + }; + @[[ "$$(cat /tmp/swagger-lint-ref-siblings-errors.log | wc -l)" -eq "0" ]] \ + || (echo "Error: $$(cat /tmp/swagger-lint-ref-siblings-errors.log | wc -l) files have \$$ref(s) with siblings!" \ + && false) + +swagger-lint: + @echo "make swagger-lint" + @spectral lint --fail-severity=warn api/swagger.yml + +# https://goswagger.io/usage/mixin.html +# https://goswagger.io/usage/flatten.html +swagger-concat: ##- (opt) Regenerates api/swagger.yml based on api/paths/*. + @echo "make swagger-concat" + @mkdir -p api/tmp + @rm -rf api/tmp/* + @swagger mixin \ + --output=api/tmp/tmp.yml \ + --format=yaml \ + --keep-spec-order \ + api/config/main.yml api/paths/* \ + -q + @swagger flatten api/tmp/tmp.yml \ + --output=api/swagger.yml \ + --format=yaml \ + -q + @sed -i '1s@^@# // Code generated by "make swagger"; DO NOT EDIT.\n@' api/swagger.yml + +swagger-server: swagger-generate swagger-lint-ref-siblings swagger-validate swagger-lint ##- (opt) Lint/validate api/swagger.yml and generate /internal/types. + +# https://goswagger.io/generate/server.html +# Note that we first flag all files to delete (as previously generated), regenerate, then delete all still flagged files +# This allows us to ensure that any filewatchers (VScode) don't panic as these files are removed. +# --keep-spec-order is broken (/tmp spec resolving): https://github.com/go-swagger/go-swagger/issues/2216 +swagger-generate: ##- (opt) Generate swagger /internal/types. + @echo "make swagger-generate" + @rm -rf tmp/testdata/types + @mkdir -p tmp/testdata/types + @swagger generate server \ + --allow-template-override \ + --template-dir=api/templates \ + --spec=api/swagger.yml \ + --server-package=tmp/testdata/types \ + --model-package=tmp/testdata/types \ + --exclude-main \ + --skip-validation \ + --config-file=api/config/go-swagger-config.yml \ + -q + @find tmp/testdata/types -type f -exec sed -i "s|${GO_MODULE_NAME}/tmp/testdata/types|${GO_MODULE_NAME}/internal/types|g" {} \; + rsync -au --ignore-times --delete tmp/testdata/types/ internal/types/ + +swagger-validate: ##- (opt) Validate api/swagger.yml. + @echo "make swagger-validate" + @swagger validate --skip-warnings --stop-on-error -q api/swagger.yml + +watch-swagger: ##- Watches *.yml|yaml|gotmpl files in /api and runs 'make swagger' on modifications. + @echo "Watching /api/**/*.yml|yaml|gotmpl. Use Ctrl-c to stop a run or exit." + watchexec -p -w api -i tmp -i api/swagger.yml --exts yml,yaml,gotmpl $(MAKE) swagger + +### ----------------------- +# --- Binary checks +### ----------------------- + +# Got license issues with some dependencies? Provide a custom lichen --config +# see https://github.com/uw-labs/lichen#config +get-licenses: ##- Prints licenses of embedded modules in the compiled bin/app. + lichen bin/app + +get-embedded-modules: ##- Prints embedded modules in the compiled bin/app. + go version -m -v bin/app + +get-embedded-modules-count: ##- (opt) Prints count of embedded modules in the compiled bin/app. + go version -m -v bin/app | grep $$'\tdep' | wc -l + +check-embedded-modules-go-not: ##- (opt) Checks embedded modules in compiled bin/app against go.not, throws on occurrence. + @echo "make check-embedded-modules-go-not" + @(mkdir -p tmp 2> /dev/null && go version -m -v bin/app > tmp/.modules) + grep -f go.not -F tmp/.modules && (echo "go.not: Found disallowed embedded module(s) in bin/app!" && exit 1) || exit 0 + +trivy-report: ##- Prints trivy report for all severities without failing + @echo "make trivy-report" + @trivy fs /app --exit-code 0 --quiet + +trivy: ##- Runs trivy analysis and fails on HIGH, CRITICAL vulnerabilities. + @echo "make trivy" + @trivy fs /app --exit-code 1 --severity HIGH,CRITICAL --quiet + +govulncheck: ##- Runs govulncheck. + @echo "make govulncheck" + @govulncheck /app/... + +### ----------------------- +# --- Git related +### ----------------------- + +# This is the default upstream go-starter branch we will use for our comparisons. +# You may use a different tag/branch/commit like this: +# - Merge with a specific tag, e.g. `go-starter-2021-10-19`: `GIT_GO_STARTER_TARGET=go-starter-2021-10-19 make git-merge-go-starter` +# - Merge with a specific branch, e.g. `mr/housekeeping`: `GIT_GO_STARTER_TARGET=go-starter/mr/housekeeping make git-merge-go-starter` (heads up! it's `go-starter/`) +# - Merge with a specific commit, e.g. `e85bedb9`: `GIT_GO_STARTER_TARGET=e85bedb94c3562602bc23d2bfd09fca3b13d1e02 make git-merge-go-starter` +GIT_GO_STARTER_TARGET ?= go-starter/master +GIT_GO_STARTER_BASE ?= $(GIT_GO_STARTER_TARGET:go-starter/%=%) + +git-fetch-go-starter: ##- (opt) Fetches upstream GIT_GO_STARTER_TARGET (creating git remote 'go-starter'). + @echo "GIT_GO_STARTER_TARGET=${GIT_GO_STARTER_TARGET} GIT_GO_STARTER_BASE=${GIT_GO_STARTER_BASE}" + @git config remote.go-starter.url >&- || git remote add go-starter https://github.com/allaboutapps/go-starter.git + @git fetch go-starter ${GIT_GO_STARTER_BASE} + +git-compare-go-starter: ##- (opt) Compare upstream GIT_GO_STARTER_TARGET to HEAD displaying commits away and git log. + @$(MAKE) git-fetch-go-starter + @echo "Commits away from upstream go-starter ${GIT_GO_STARTER_TARGET}:" + git --no-pager rev-list --pretty=oneline --left-only --count ${GIT_GO_STARTER_TARGET}...HEAD + @echo "" + @echo "Git log:" + git --no-pager log --left-only --pretty="%C(Yellow)%h %C(reset)%ad (%C(Green)%cr%C(reset))%x09 %C(Cyan)%an: %C(reset)%s" --abbrev-commit --count ${GIT_GO_STARTER_TARGET}...HEAD + +git-merge-go-starter: ##- Merges upstream GIT_GO_STARTER_TARGET into current HEAD. + @$(MAKE) git-compare-go-starter + @(echo "" \ + && echo "Attempting to execute 'git merge --no-commit --no-ff --allow-unrelated-histories ${GIT_GO_STARTER_TARGET}' into your current HEAD." \ + && echo -n "Are you sure? [y/N]" \ + && read ans && [ $${ans:-N} = y ]) || exit 1 + git merge --no-commit --no-ff --allow-unrelated-histories ${GIT_GO_STARTER_TARGET} || true + @echo "Done. We recommend to run 'make force-module-name' to automatically fix all import paths." + +### ----------------------- +# --- Helpers +### ----------------------- + +clean: ##- Cleans ./tmp and ./api/tmp folder. + @echo "make clean" + @rm -rf tmp/* 2> /dev/null + @rm -rf api/tmp/* 2> /dev/null + +get-module-name: ##- Prints current go module-name (pipeable). + @echo "${GO_MODULE_NAME}" + +info-module-name: ##- (opt) Prints current go module-name. + @echo "go module-name: '${GO_MODULE_NAME}'" + +set-module-name: ##- Wizard to set a new go module-name. + @rm -f tmp/.modulename + @$(MAKE) info-module-name + @echo "Enter new go module-name:" \ + && read new_module_name \ + && echo "new go module-name: '$${new_module_name}'" \ + && echo -n "Are you sure? [y/N]" \ + && read ans && [ $${ans:-N} = y ] \ + && echo -n "Please wait..." \ + && find . -not -path '*/\.*' -not -path './Makefile' -type f -exec sed -i "s|${GO_MODULE_NAME}|$${new_module_name}|g" {} \; \ + && echo "new go module-name: '$${new_module_name}'!" + @rm -f tmp/.modulename + +force-module-name: ##- Overwrite occurrences of 'allaboutapps.dev/aw/go-starter' with current go module-name. + find . -not -path '*/\.*' -not -path './Makefile' -type f -exec sed -i "s|allaboutapps.dev/aw/go-starter|${GO_MODULE_NAME}|g" {} \; + +get-go-ldflags: ##- (opt) Prints used -ldflags as evaluated in Makefile used in make go-build + @echo $(LDFLAGS) + +help: ##- Show common make targets. + @echo -e "usage: make \033[36m\033[0m" + @echo "note: use 'make help-all' to see all make targets." + @awk 'BEGIN {FS = ":.*#\#-"; } /^[a-zA-Z_-]+:.*?/ && !/(opt)/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^# --- / { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +help-all: ##- Show all make targets. + @echo -e "usage: make \033[36m\033[0m" + @awk 'BEGIN {FS = ":.*#\#-"; } /^[a-zA-Z_-]+:.*?/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^# --- / { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +### ----------------------- +# --- Changelog +### ----------------------- + +changelog-prerelease: ##- Generates a changelog prerelease. + @echo "make changelog-prerelease" + @changie batch 0.0.0 --prerelease prerelease-$(shell date +%Y-%m-%d-%H%M%S) --move-dir prerelease + @git add --all + @git commit -m "PRERELEASE: $(shell date +%Y-%m-%d-%H%M%S)" + +changelog-release: ##- Generates a changelog release using the $(VERSION) variable. Usage: make changelog-release VERSION=24.11.0 + @echo "make changelog-release with version $(VERSION)" + @changie batch $(VERSION) --include prerelease --remove-prereleases + @changie merge + @git add --all + @git commit -m "RELEASE: $(VERSION)" + +### ----------------------- +# --- Make variables +### ----------------------- + +# only evaluated if required by a recipe +# http://make.mad-scientist.net/deferred-simple-variable-expansion/ + +# go module name (as in go.mod) +GO_MODULE_NAME = $(eval GO_MODULE_NAME := $$(shell \ + (mkdir -p tmp 2> /dev/null && cat tmp/.modulename 2> /dev/null) \ + || (gsdev modulename 2> /dev/null | tee tmp/.modulename) || echo "unknown" \ +))$(GO_MODULE_NAME) + +# https://medium.com/the-go-journey/adding-version-information-to-go-binaries-e1b79878f6f2 +ARG_COMMIT = $(eval ARG_COMMIT := $$(shell \ + (git rev-list -1 HEAD 2> /dev/null) \ + || (echo "unknown") \ +))$(ARG_COMMIT) + +ARG_BUILD_DATE = $(eval ARG_BUILD_DATE := $$(shell \ + (date -Is 2> /dev/null || date 2> /dev/null || echo "unknown") \ +))$(ARG_BUILD_DATE) + +# https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications +LDFLAGS = $(eval LDFLAGS := "\ +-X '$(GO_MODULE_NAME)/internal/config.ModuleName=$(GO_MODULE_NAME)'\ +-X '$(GO_MODULE_NAME)/internal/config.Commit=$(ARG_COMMIT)'\ +-X '$(GO_MODULE_NAME)/internal/config.BuildDate=$(ARG_BUILD_DATE)'\ +")$(LDFLAGS) + +### ----------------------- +# --- Special targets +### ----------------------- + +# https://www.gnu.org/software/make/manual/html_node/Special-Targets.html +# https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html +# ignore matching file/make rule combinations in working-dir +.PHONY: test help + +# https://unix.stackexchange.com/questions/153763/dont-stop-makeing-if-a-command-fails-but-check-exit-status +# https://www.gnu.org/software/make/manual/html_node/One-Shell.html +# required to ensure make fails if one recipe fails (even on parallel jobs) and on pipefails +.ONESHELL: + +# # normal POSIX bash shell mode +# SHELL = /bin/bash +# .SHELLFLAGS = -cEeuo pipefail + +# wrapped make time tracing shell, use it via MAKE_TRACE_TIME=true make +SHELL = /app/rksh +.SHELLFLAGS = $@ \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..40d58ce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not publicly open issues related to security concerns! + +To report a security issue, email `security [AT] allaboutapps [DOT] at` and include the word `SECURITY` in the subject line: +* Do not include anyone else on the disclosure email. +* Tell us what you found, how to reproduce it, and any concerns you have about it. + +We will do our best to get back to you ASAP. diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..abc710f --- /dev/null +++ b/api/README.md @@ -0,0 +1,22 @@ +# `/api` + +OpenAPI/Swagger specs, JSON schema files, protocol definition files. + +Use `make swagger` do generate `/internal/types/**/*.go` from these specs. You may use `make watch-swagger` while writing these schemas to automatically execute `make swagger` every time there are changes (especially useful if you have `http://localhost:8081/` opened in your browser while mutating these specs). + +The final `/api/swagger.yml` is auto-generated from the following specs: +1. The skeleton spec at `/api/config/main.yml`, +2. all path specs living within `/api/paths/*.yml` and +3. any `/api/definitions/*.yml` referenced by in step 1 and 2. + +Further reading: +* [Swagger v2 specification](https://swagger.io/specification/v2/) +* [go-swagger](https://github.com/go-swagger/go-swagger) +* [go-swagger: Schema Generation Rules](https://github.com/go-swagger/go-swagger/blob/master/docs/use/models/schemas.md) + + +Related examples regarding this project layout: + +* https://github.com/golang-standards/project-layout/blob/master/api/README.md +* https://github.com/kubernetes/kubernetes/tree/master/api +* https://github.com/moby/moby/tree/master/api \ No newline at end of file diff --git a/api/config/go-swagger-config.yml b/api/config/go-swagger-config.yml new file mode 100644 index 0000000..30e2131 --- /dev/null +++ b/api/config/go-swagger-config.yml @@ -0,0 +1,16 @@ +layout: + models: + - name: definition + source: asset:model + target: "{{ joinFilePath .Target .ModelPackage }}" + file_name: "{{ (snakize (pascalize .Name)) }}.go" + operations: + - name: parameters + source: asset:serverParameter + target: "{{ joinFilePath .Target .ServerPackage .Package }}" + file_name: "{{ (snakize (pascalize .Name)) }}_parameters.go" + application: + - name: builder + source: asset:serverBuilder + target: "{{ joinFilePath .Target .ServerPackage }}" + file_name: "spec_handlers.go" diff --git a/api/config/main.yml b/api/config/main.yml new file mode 100644 index 0000000..d699e0d --- /dev/null +++ b/api/config/main.yml @@ -0,0 +1,37 @@ +# This is our base swagger file and the primary mixin target. +# Everything in definitions|paths/*.yml will be mixed through +# and finally flattened into the actual swagger.yml in this dir. +consumes: + - application/json +produces: + - application/json +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 + description: API documentation +paths: {} +securityDefinitions: + Bearer: + type: apiKey + name: Authorization + in: header + description: |- + Access token for application access, **must** include "Bearer " prefix. + Example: `Bearer b4a94a42-3ea2-4af3-9699-8bcbfee6e6d2` + x-keyPrefix: "Bearer " + Management: + type: apiKey + in: query + description: Management secret, used for monitoring and infrastructure related calls + name: mgmt-secret +definitions: + # Any definitions that are not yet used within paths/*.yml are automatically removed from the resulting swagger.yml. + # You may reference some definitions that you *always* want to be included here. + # -- + # Always include orderDir so we can test binding it to db queries directly. + orderDir: + type: string + enum: + - asc + - desc diff --git a/api/config/swagger-ui-local-translator.js b/api/config/swagger-ui-local-translator.js new file mode 100644 index 0000000..7a9bf6b --- /dev/null +++ b/api/config/swagger-ui-local-translator.js @@ -0,0 +1,122 @@ +// https://github.com/swagger-api/swagger-ui/blob/master/docker/configurator/translator.js +// Based on commit in 02758b8125dbf38763cfd5d4f91c7c803e9bd0ad +// Nov 08, 2018 +// see "AAA change" for applied changes + +// Converts an object of environment variables into a Swagger UI config object +const configSchema = require("./variables") + +const defaultBaseConfig = { + url: { + value: "https://petstore.swagger.io/v2/swagger.json", + schema: { + type: "string", + base: true + } + }, + dom_id: { + value: "#swagger-ui", + schema: { + type: "string", + base: true + } + }, + deepLinking: { + value: "true", + schema: { + type: "boolean", + base: true + } + }, + presets: { + value: `[\n SwaggerUIBundle.presets.apis,\n SwaggerUIStandalonePreset\n]`, + schema: { + type: "array", + base: true + } + }, + plugins: { + value: `[\n SwaggerUIBundle.plugins.DownloadUrl\n]`, + schema: { + type: "array", + base: true + } + }, + layout: { + value: "StandaloneLayout", + schema: { + type: "string", + base: true + } + }, + // AAA change START: interspect requests to 8081 and instead pass them to 8080 where our server runs + requestInterceptor: { + value: `function (request) { + request.url = request.url.split(":8081/").join(":8080/"); + console.log("[requestInterceptor] :8080", request); + return request; +}`, + schema: { + type: "function", + base: true + } + } + // AAA change END +} + +function objectToKeyValueString(env, { injectBaseConfig = false, schema = configSchema, baseConfig = defaultBaseConfig } = {}) { + let valueStorage = injectBaseConfig ? Object.assign({}, baseConfig) : {} + const keys = Object.keys(env) + + // Compute an intermediate representation that holds candidate values and schemas. + // + // This is useful for deduping between multiple env keys that set the same + // config variable. + + keys.forEach(key => { + const varSchema = schema[key] + const value = env[key] + + if (!varSchema) return + + if (varSchema.onFound) { + varSchema.onFound() + } + + const storageContents = valueStorage[varSchema.name] + + if (storageContents) { + if (varSchema.legacy === true && !storageContents.schema.base) { + // If we're looking at a legacy var, it should lose out to any already-set value + // except for base values + return + } + delete valueStorage[varSchema.name] + } + + valueStorage[varSchema.name] = { + value, + schema: varSchema + } + }) + + // Compute a key:value string based on valueStorage's contents. + + let result = "" + + Object.keys(valueStorage).forEach(key => { + const value = valueStorage[key] + + const escapedName = /[^a-zA-Z0-9]/.test(key) ? `"${key}"` : key + + if (value.schema.type === "string") { + result += `${escapedName}: "${value.value}",\n` + } else { + result += `${escapedName}: ${value.value === "" ? `undefined` : value.value},\n` + } + }) + + return result.trim() +} + +module.exports = objectToKeyValueString \ No newline at end of file diff --git a/api/definitions/auth.yml b/api/definitions/auth.yml new file mode 100644 index 0000000..e105e14 --- /dev/null +++ b/api/definitions/auth.yml @@ -0,0 +1,195 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: {} +definitions: + User: + type: object + required: + - id + properties: + id: + type: string + format: uuid4 + description: ID of user + example: 891d37d3-c74f-493e-aea8-af73efd92016 + GetUserInfoResponse: + type: object + required: + - sub + - updated_at + properties: + sub: + type: string + description: ID of user + example: 82ebdfad-c586-4407-a873-4cc1c33d56fc + updated_at: + type: integer + description: Unix timestamp the user's info was last updated at + example: 1591960808 + email: + type: string + format: email + description: Email address of user, if available + maxLength: 255 + example: user@example.com + scopes: + type: array + items: + type: string + enum: + - "app" + - "cms" + description: Auth-Scopes of the user, if available + example: ["app"] + PostChangePasswordPayload: + type: object + required: + - currentPassword + - newPassword + properties: + currentPassword: + description: Current password of user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + newPassword: + description: New password to set for user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + PostForgotPasswordCompletePayload: + type: object + required: + - token + - password + properties: + password: + description: New password to set for user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + token: + description: Password reset token sent via email + type: string + format: uuid4 + example: ec16f032-3c44-4148-bbcc-45557466fa74 + PostForgotPasswordPayload: + type: object + required: + - username + properties: + username: + description: Username to initiate password reset for + type: string + format: email + maxLength: 255 + minLength: 1 + example: user@example.com + PostLoginPayload: + type: object + required: + - username + - password + properties: + password: + description: Password of user to authenticate as + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + username: + description: Username of user to authenticate as + type: string + format: email + maxLength: 255 + minLength: 1 + example: user@example.com + PostLoginResponse: + type: object + required: + - access_token + - token_type + - expires_in + - refresh_token + properties: + access_token: + description: Access token required for accessing protected API endpoints + type: string + format: uuid4 + example: c1247d8d-0d65-41c4-bc86-ec041d2ac437 + expires_in: + description: Access token expiry in seconds + type: integer + format: int64 + example: 86400 + refresh_token: + description: Refresh token for refreshing the access token once it expires + type: string + format: uuid4 + example: 1dadb3bd-50d8-485d-83a3-6111392568f0 + token_type: + description: "Type of access token, will always be `bearer`" + type: string + example: bearer + RegisterResponse: + type: object + required: + - requiresConfirmation + properties: + requiresConfirmation: + description: Indicates whether the registration process requires email confirmation + type: boolean + example: true + PostLogoutPayload: + type: object + properties: + refresh_token: + description: Optional refresh token to delete while logging out + type: string + format: uuid4 + example: 700ebed3-40f7-4211-bc83-a89b22b9875e + PostRefreshPayload: + type: object + required: + - refresh_token + properties: + refresh_token: + description: Refresh token to use for retrieving new token set + type: string + format: uuid4 + example: 7503cd8a-c921-4368-a32d-6c1d01d86da9 + PostRegisterPayload: + type: object + required: + - username + - password + properties: + password: + description: Password to register with + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + username: + description: Username to register with + type: string + format: email + maxLength: 255 + minLength: 1 + example: user@example.com + DeleteUserAccountPayload: + type: object + required: + - currentPassword + properties: + currentPassword: + description: Current password of user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple diff --git a/api/definitions/common.yml b/api/definitions/common.yml new file mode 100644 index 0000000..563035b --- /dev/null +++ b/api/definitions/common.yml @@ -0,0 +1,47 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: {} +definitions: + Paginatable: + type: object + required: + - limit + - offset + - total + properties: + limit: + type: integer + description: Actual limit applied to request + offset: + type: integer + description: Actual offset applied to request + total: + type: integer + description: Total number of records available +parameters: + offsetParam: + type: integer + in: query + name: offset + description: Offset used for pagination, number of records to skip + default: 0 + minimum: 0 + limitParam: + type: integer + in: query + name: limit + description: Limit used for pagination, number of records to retrieve + default: 50 + minimum: 1 + maximum: 500 + orderDirParam: + type: string + in: query + name: orderDir + description: Direction of order applied, defaults to `asc` if omitted. `asc` will sort `NULL` values at the end of the list. + enum: + - asc + - desc + default: asc diff --git a/api/definitions/errors.yml b/api/definitions/errors.yml new file mode 100644 index 0000000..1865684 --- /dev/null +++ b/api/definitions/errors.yml @@ -0,0 +1,79 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: {} +definitions: + PublicHTTPErrorType: + type: string + description: Type of error returned, should be used for client-side error handling + enum: + - generic + # push + - PUSH_TOKEN_ALREADY_EXISTS + - OLD_PUSH_TOKEN_NOT_FOUND + # files + - ZERO_FILE_SIZE + # auth + - USER_DEACTIVATED + - INVALID_PASSWORD + - NOT_LOCAL_USER + - TOKEN_NOT_FOUND + - TOKEN_EXPIRED + - USER_ALREADY_EXISTS + - MALFORMED_TOKEN + - LAST_AUTHENTICATED_AT_EXCEEDED + - MISSING_SCOPES + PublicHTTPError: + type: object + required: + - status + - type + - title + properties: + detail: + description: "More detailed, human-readable, optional explanation of the error" + type: string + example: User is lacking permission to access this resource + status: + description: HTTP status code returned for the error + type: integer + format: int64 + maximum: 599 + minimum: 100 + x-go-name: Code + example: 403 + title: + description: "Short, human-readable description of the error" + type: string + example: Forbidden + type: + $ref: "#/definitions/PublicHTTPErrorType" + PublicHTTPValidationError: + allOf: + - $ref: "#/definitions/PublicHTTPError" + type: object + required: + - validationErrors + properties: + validationErrors: + description: List of errors received while validating payload against schema + type: array + items: + $ref: "#/definitions/HTTPValidationErrorDetail" + HTTPValidationErrorDetail: + type: object + required: + - key + - in + - error + properties: + error: + description: Error describing field validation failure + type: string + in: + description: Indicates how the invalid field was provided + type: string + key: + description: Key of field failing validation + type: string diff --git a/api/definitions/push.yml b/api/definitions/push.yml new file mode 100644 index 0000000..28584f7 --- /dev/null +++ b/api/definitions/push.yml @@ -0,0 +1,28 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: {} +definitions: + PutUpdatePushTokenPayload: + type: object + required: + - newToken + - provider + properties: + newToken: + description: New push token for given provider. + type: string + maxLength: 500 + example: 1c91e550-8167-439c-8021-dee7de2f7e96 + oldToken: + description: Old token that can be deleted if present. + type: string + maxLength: 500 + example: 495179de-b771-48f0-aab2-8d23701b0f02 + x-nullable: true + provider: + description: Identifier of the provider the token is for (eg. "fcm", "apn"). Currently only "fcm" is supported. + type: string + maxLength: 500 + example: fcm diff --git a/api/paths/auth.yml b/api/paths/auth.yml new file mode 100644 index 0000000..abd246c --- /dev/null +++ b/api/paths/auth.yml @@ -0,0 +1,289 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +responses: + InvalidPasswordResponse: + description: "PublicHTTPValidationError, type `INVALID_PASSWORD`" + schema: + $ref: ../definitions/errors.yml#/definitions/PublicHTTPValidationError + AuthUnauthorizedResponse: + description: PublicHTTPError + schema: + $ref: ../definitions/errors.yml#/definitions/PublicHTTPError + AuthForbiddenResponse: + description: "PublicHTTPError, type `USER_DEACTIVATED`/`NOT_LOCAL_USER`" + schema: + $ref: ../definitions/errors.yml#/definitions/PublicHTTPError + ValidationError: + description: PublicHTTPValidationError + schema: + $ref: "../definitions/errors.yml#/definitions/PublicHTTPValidationError" +parameters: + registrationTokenParam: + type: string + format: uuid4 + in: path + name: registrationToken + description: Registration token to complete the registration process + required: true +paths: + /api/v1/auth/change-password: + post: + security: + - Bearer: [] + description: |- + After successful password change, all current access and refresh tokens are + invalidated and a new set of auth tokens is returned + tags: + - auth + summary: Change local user's password + operationId: PostChangePasswordRoute + parameters: + - name: Payload + in: body + schema: + $ref: ../definitions/auth.yml#/definitions/PostChangePasswordPayload + responses: + "200": + description: PostLoginResponse + schema: + $ref: ../definitions/auth.yml#/definitions/PostLoginResponse + "400": + $ref: "#/responses/InvalidPasswordResponse" + "401": + $ref: "#/responses/AuthUnauthorizedResponse" + "403": + $ref: "#/responses/AuthForbiddenResponse" + + /api/v1/auth/forgot-password: + post: + description: |- + Initiates a password reset for a local user, sending an email with a password + reset link to the provided email address if a user account exists. Will always + succeed, even if no user was found in order to prevent user enumeration + tags: + - auth + summary: Initiate password reset for local user + operationId: PostForgotPasswordRoute + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/auth.yml#/definitions/PostForgotPasswordPayload" + responses: + "204": + description: Success + "400": + $ref: "#/responses/ValidationError" + /api/v1/auth/forgot-password/complete: + post: + description: |- + Completes a password reset for a local user, using the password reset token sent via email + to confirm user access, setting the new password if successful. All current access and refresh + tokens are invalidated and a new set of auth tokens is returned + tags: + - auth + summary: Completes password reset for local user + operationId: PostForgotPasswordCompleteRoute + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/auth.yml#/definitions/PostForgotPasswordCompletePayload" + responses: + "200": + description: PostLoginResponse + schema: + $ref: "../definitions/auth.yml#/definitions/PostLoginResponse" + "400": + $ref: "#/responses/InvalidPasswordResponse" + "403": + $ref: "#/responses/AuthForbiddenResponse" + "404": + description: "PublicHTTPError, type `TOKEN_NOT_FOUND`" + schema: + $ref: "../definitions/errors.yml#/definitions/PublicHTTPError" + "409": + description: "PublicHTTPError, type `TOKEN_EXPIRED`" + schema: + $ref: "../definitions/errors.yml#/definitions/PublicHTTPError" + /api/v1/auth/login: + post: + description: Returns an access and refresh token on successful authentication + tags: + - auth + summary: Login with local user + operationId: PostLoginRoute + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/auth.yml#/definitions/PostLoginPayload" + responses: + "200": + description: PostLoginResponse + schema: + $ref: "../definitions/auth.yml#/definitions/PostLoginResponse" + "400": + $ref: "#/responses/ValidationError" + "401": + $ref: "#/responses/AuthUnauthorizedResponse" + "403": + description: "PublicHTTPError, type `USER_DEACTIVATED`" + schema: + $ref: "../definitions/errors.yml#/definitions/PublicHTTPError" + /api/v1/auth/logout: + post: + security: + - Bearer: [] + description: |- + Logs the local user out, destroying the provided access token. + A refresh token can optionally be provided, destroying it as well if found. + tags: + - auth + summary: Log out local user + operationId: PostLogoutRoute + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/auth.yml#/definitions/PostLogoutPayload" + responses: + "204": + description: Success + "400": + $ref: "#/responses/ValidationError" + "401": + $ref: "#/responses/AuthUnauthorizedResponse" + /api/v1/auth/refresh: + post: + description: |- + Returns a fresh set of access and refresh tokens if a valid refresh token was provided. + The old refresh token used to authenticate the request will be invalidated. + tags: + - auth + summary: Refresh tokens + operationId: PostRefreshRoute + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/auth.yml#/definitions/PostRefreshPayload" + responses: + "200": + description: PostLoginResponse + schema: + $ref: "../definitions/auth.yml#/definitions/PostLoginResponse" + "400": + $ref: "#/responses/ValidationError" + "401": + $ref: "#/responses/AuthUnauthorizedResponse" + "403": + description: "PublicHTTPError, type `USER_DEACTIVATED`" + schema: + $ref: "../definitions/errors.yml#/definitions/PublicHTTPError" + /api/v1/auth/register: + post: + description: | + Registers a local user. If the registration process requires a confirmation + the status code `202` is returned without auth tokens. Afterwards the registration needs to be confirmed + using the `POST /api/v1/auth/register/{registrationToken}` endpoint. + tags: + - auth + summary: Registers a local user + operationId: PostRegisterRoute + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/auth.yml#/definitions/PostRegisterPayload" + responses: + "200": + description: PostLoginResponse + schema: + $ref: "../definitions/auth.yml#/definitions/PostLoginResponse" + "202": + description: RegisterResponse + schema: + $ref: "../definitions/auth.yml#/definitions/RegisterResponse" + "400": + $ref: "#/responses/InvalidPasswordResponse" + "409": + description: "PublicHTTPError, type `USER_ALREADY_EXISTS`" + schema: + $ref: "../definitions/errors.yml#/definitions/PublicHTTPError" + get: + summary: Page to complete registration + tags: + - auth + operationId: GetCompleteRegisterRoute + parameters: + - name: token + in: query + type: string + format: uuid4 + description: Registration token to complete the registration process + required: true + produces: + - text/html + responses: + "200": + description: Page to complete registration + /api/v1/auth/register/{registrationToken}: + post: + summary: Complete registration + description: |- + Completes registration for a local user + tags: + - auth + operationId: PostCompleteRegisterRoute + parameters: + - $ref: "#/parameters/registrationTokenParam" + responses: + "200": + description: PostLoginResponse + schema: + $ref: "../definitions/auth.yml#/definitions/PostLoginResponse" + "401": + description: Unauthorized + /api/v1/auth/userinfo: + get: + summary: Get user info + description: |- + Returns user information compatible with the OpenID Connect Core 1.0 specification. + Information returned depends on the requesting user as some data is only available if an app user profile exists. + security: + - Bearer: [] + operationId: GetUserInfoRoute + tags: + - auth + responses: + "200": + description: GetUserInfoResponse + schema: + $ref: "../definitions/auth.yml#/definitions/GetUserInfoResponse" + /api/v1/auth/account: + delete: + summary: Delete user account + description: |- + Completely delete the user account. This action is irreversible and therefore requires additional password authentication. + security: + - Bearer: [] + operationId: DeleteUserAccountRoute + parameters: + - name: Payload + in: body + schema: + $ref: ../definitions/auth.yml#/definitions/DeleteUserAccountPayload + tags: + - auth + responses: + "204": + description: NoContent + "400": + $ref: "#/responses/InvalidPasswordResponse" + "401": + $ref: "#/responses/AuthUnauthorizedResponse" + "403": + $ref: "#/responses/AuthForbiddenResponse" \ No newline at end of file diff --git a/api/paths/common.yml b/api/paths/common.yml new file mode 100644 index 0000000..6238720 --- /dev/null +++ b/api/paths/common.yml @@ -0,0 +1,71 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: + /swagger.yml: + get: + summary: Get swagger.yml + operationId: GetSwaggerRoute + produces: + - text/plain + description: |- + OpenAPI Specification ver. 2 (fka Swagger) + Returns our handcrafted and validated `swagger.yml`. + tags: + - common + responses: + "200": + description: OK + /-/ready: + get: + summary: Get ready (readiness probe) + operationId: GetReadyRoute + produces: + - text/plain + description: |- + This endpoint returns 200 when the service is ready to serve traffic. + Does read-only probes apart from the general server ready state. + Note that /-/ready is typically public (and not shielded by a mgmt-secret), we thus prevent information leakage here and only return `"Ready."`. + tags: + - common + responses: + "200": + description: Ready. + "521": + description: Not ready. + /-/healthy: + get: + security: + - Management: [] + summary: Get healthy (liveness probe) + operationId: GetHealthyRoute + produces: + - text/plain + description: |- + This endpoint returns 200 when the service is healthy. + Returns an human readable string about the current service status. + In addition to readiness probes, it performs actual write probes. + Note that /-/healthy is private (shielded by the mgmt-secret) as it may expose sensitive information about your service. + tags: + - common + responses: + "200": + description: Ready. + "521": + description: Not ready. + /-/version: + get: + security: + - Management: [] + summary: Get version + operationId: GetVersionRoute + produces: + - text/plain + description: |- + This endpoint returns the module name, commit and build-date baked into the app binary. + tags: + - common + responses: + "200": + description: "ModuleName @ Commit (BuildDate)" diff --git a/api/paths/push.yml b/api/paths/push.yml new file mode 100644 index 0000000..1f33da0 --- /dev/null +++ b/api/paths/push.yml @@ -0,0 +1,33 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: + /api/v1/push/token: + put: + security: + - Bearer: [] + description: |- + Adds a push token for the given provider to the current user. + If the oldToken is present it will be deleted. + Currently only the provider 'fcm' is supported. + tags: + - push + summary: Adds a push token to the user + operationId: PutUpdatePushTokenRoute + parameters: + - name: Payload + in: body + schema: + "$ref": "../definitions/push.yml#/definitions/PutUpdatePushTokenPayload" + responses: + "200": + description: OK + "404": + description: PublicHTTPError, type `OLD_PUSH_TOKEN_NOT_FOUND` + schema: + "$ref": "../definitions/errors.yml#/definitions/PublicHTTPError" + "409": + description: PublicHTTPError, type `PUSH_TOKEN_ALREADY_EXISTS` + schema: + "$ref": "../definitions/errors.yml#/definitions/PublicHTTPError" diff --git a/api/paths/well_known.yml b/api/paths/well_known.yml new file mode 100644 index 0000000..0574e30 --- /dev/null +++ b/api/paths/well_known.yml @@ -0,0 +1,27 @@ +swagger: "2.0" +info: + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: + /.well-known/apple-app-site-association: + get: + summary: Apple App Site Association + description: |- + Returns the Apple App Site Association file. + operationId: GetAppleAppSiteAssociationRoute + tags: + - well-known + responses: + "200": + description: Apple App Site Association + /.well-known/assetlinks.json: + get: + summary: Android Digital Asset Links + description: |- + Returns the Android Digital Asset Links file. + operationId: GetAndroidDigitalAssetLinksRoute + tags: + - well-known + responses: + "200": + description: Android Digital Asset Links \ No newline at end of file diff --git a/api/swagger.yml b/api/swagger.yml new file mode 100644 index 0000000..81e1585 --- /dev/null +++ b/api/swagger.yml @@ -0,0 +1,738 @@ +# // Code generated by "make swagger"; DO NOT EDIT. +consumes: +- application/json +produces: +- application/json +swagger: "2.0" +info: + description: API documentation + title: allaboutapps.dev/aw/go-starter + version: 0.1.0 +paths: + /-/healthy: + get: + security: + - Management: [] + description: |- + This endpoint returns 200 when the service is healthy. + Returns an human readable string about the current service status. + In addition to readiness probes, it performs actual write probes. + Note that /-/healthy is private (shielded by the mgmt-secret) as it may expose sensitive information about your service. + produces: + - text/plain + tags: + - common + summary: Get healthy (liveness probe) + operationId: GetHealthyRoute + responses: + "200": + description: Ready. + "521": + description: Not ready. + /-/ready: + get: + description: |- + This endpoint returns 200 when the service is ready to serve traffic. + Does read-only probes apart from the general server ready state. + Note that /-/ready is typically public (and not shielded by a mgmt-secret), we thus prevent information leakage here and only return `"Ready."`. + produces: + - text/plain + tags: + - common + summary: Get ready (readiness probe) + operationId: GetReadyRoute + responses: + "200": + description: Ready. + "521": + description: Not ready. + /-/version: + get: + security: + - Management: [] + description: This endpoint returns the module name, commit and build-date baked + into the app binary. + produces: + - text/plain + tags: + - common + summary: Get version + operationId: GetVersionRoute + responses: + "200": + description: ModuleName @ Commit (BuildDate) + /.well-known/apple-app-site-association: + get: + description: Returns the Apple App Site Association file. + tags: + - well-known + summary: Apple App Site Association + operationId: GetAppleAppSiteAssociationRoute + responses: + "200": + description: Apple App Site Association + /.well-known/assetlinks.json: + get: + description: Returns the Android Digital Asset Links file. + tags: + - well-known + summary: Android Digital Asset Links + operationId: GetAndroidDigitalAssetLinksRoute + responses: + "200": + description: Android Digital Asset Links + /api/v1/auth/account: + delete: + security: + - Bearer: [] + description: Completely delete the user account. This action is irreversible + and therefore requires additional password authentication. + tags: + - auth + summary: Delete user account + operationId: DeleteUserAccountRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/deleteUserAccountPayload' + responses: + "204": + description: NoContent + "400": + description: PublicHTTPValidationError, type `INVALID_PASSWORD` + schema: + $ref: '#/definitions/publicHttpValidationError' + "401": + description: PublicHTTPError + schema: + $ref: '#/definitions/publicHttpError' + "403": + description: PublicHTTPError, type `USER_DEACTIVATED`/`NOT_LOCAL_USER` + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/change-password: + post: + security: + - Bearer: [] + description: |- + After successful password change, all current access and refresh tokens are + invalidated and a new set of auth tokens is returned + tags: + - auth + summary: Change local user's password + operationId: PostChangePasswordRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postChangePasswordPayload' + responses: + "200": + description: PostLoginResponse + schema: + $ref: '#/definitions/postLoginResponse' + "400": + description: PublicHTTPValidationError, type `INVALID_PASSWORD` + schema: + $ref: '#/definitions/publicHttpValidationError' + "401": + description: PublicHTTPError + schema: + $ref: '#/definitions/publicHttpError' + "403": + description: PublicHTTPError, type `USER_DEACTIVATED`/`NOT_LOCAL_USER` + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/forgot-password: + post: + description: |- + Initiates a password reset for a local user, sending an email with a password + reset link to the provided email address if a user account exists. Will always + succeed, even if no user was found in order to prevent user enumeration + tags: + - auth + summary: Initiate password reset for local user + operationId: PostForgotPasswordRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postForgotPasswordPayload' + responses: + "204": + description: Success + "400": + description: PublicHTTPValidationError + schema: + $ref: '#/definitions/publicHttpValidationError' + /api/v1/auth/forgot-password/complete: + post: + description: |- + Completes a password reset for a local user, using the password reset token sent via email + to confirm user access, setting the new password if successful. All current access and refresh + tokens are invalidated and a new set of auth tokens is returned + tags: + - auth + summary: Completes password reset for local user + operationId: PostForgotPasswordCompleteRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postForgotPasswordCompletePayload' + responses: + "200": + description: PostLoginResponse + schema: + $ref: '#/definitions/postLoginResponse' + "400": + description: PublicHTTPValidationError, type `INVALID_PASSWORD` + schema: + $ref: '#/definitions/publicHttpValidationError' + "403": + description: PublicHTTPError, type `USER_DEACTIVATED`/`NOT_LOCAL_USER` + schema: + $ref: '#/definitions/publicHttpError' + "404": + description: PublicHTTPError, type `TOKEN_NOT_FOUND` + schema: + $ref: '#/definitions/publicHttpError' + "409": + description: PublicHTTPError, type `TOKEN_EXPIRED` + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/login: + post: + description: Returns an access and refresh token on successful authentication + tags: + - auth + summary: Login with local user + operationId: PostLoginRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postLoginPayload' + responses: + "200": + description: PostLoginResponse + schema: + $ref: '#/definitions/postLoginResponse' + "400": + description: PublicHTTPValidationError + schema: + $ref: '#/definitions/publicHttpValidationError' + "401": + description: PublicHTTPError + schema: + $ref: '#/definitions/publicHttpError' + "403": + description: PublicHTTPError, type `USER_DEACTIVATED` + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/logout: + post: + security: + - Bearer: [] + description: |- + Logs the local user out, destroying the provided access token. + A refresh token can optionally be provided, destroying it as well if found. + tags: + - auth + summary: Log out local user + operationId: PostLogoutRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postLogoutPayload' + responses: + "204": + description: Success + "400": + description: PublicHTTPValidationError + schema: + $ref: '#/definitions/publicHttpValidationError' + "401": + description: PublicHTTPError + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/refresh: + post: + description: |- + Returns a fresh set of access and refresh tokens if a valid refresh token was provided. + The old refresh token used to authenticate the request will be invalidated. + tags: + - auth + summary: Refresh tokens + operationId: PostRefreshRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postRefreshPayload' + responses: + "200": + description: PostLoginResponse + schema: + $ref: '#/definitions/postLoginResponse' + "400": + description: PublicHTTPValidationError + schema: + $ref: '#/definitions/publicHttpValidationError' + "401": + description: PublicHTTPError + schema: + $ref: '#/definitions/publicHttpError' + "403": + description: PublicHTTPError, type `USER_DEACTIVATED` + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/register: + get: + produces: + - text/html + tags: + - auth + summary: Page to complete registration + operationId: GetCompleteRegisterRoute + parameters: + - type: string + format: uuid4 + description: Registration token to complete the registration process + name: token + in: query + required: true + responses: + "200": + description: Page to complete registration + post: + description: | + Registers a local user. If the registration process requires a confirmation + the status code `202` is returned without auth tokens. Afterwards the registration needs to be confirmed + using the `POST /api/v1/auth/register/{registrationToken}` endpoint. + tags: + - auth + summary: Registers a local user + operationId: PostRegisterRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/postRegisterPayload' + responses: + "200": + description: PostLoginResponse + schema: + $ref: '#/definitions/postLoginResponse' + "202": + description: RegisterResponse + schema: + $ref: '#/definitions/registerResponse' + "400": + description: PublicHTTPValidationError, type `INVALID_PASSWORD` + schema: + $ref: '#/definitions/publicHttpValidationError' + "409": + description: PublicHTTPError, type `USER_ALREADY_EXISTS` + schema: + $ref: '#/definitions/publicHttpError' + /api/v1/auth/register/{registrationToken}: + post: + description: Completes registration for a local user + tags: + - auth + summary: Complete registration + operationId: PostCompleteRegisterRoute + parameters: + - type: string + format: uuid4 + description: Registration token to complete the registration process + name: registrationToken + in: path + required: true + responses: + "200": + description: PostLoginResponse + schema: + $ref: '#/definitions/postLoginResponse' + "401": + description: Unauthorized + /api/v1/auth/userinfo: + get: + security: + - Bearer: [] + description: |- + Returns user information compatible with the OpenID Connect Core 1.0 specification. + Information returned depends on the requesting user as some data is only available if an app user profile exists. + tags: + - auth + summary: Get user info + operationId: GetUserInfoRoute + responses: + "200": + description: GetUserInfoResponse + schema: + $ref: '#/definitions/getUserInfoResponse' + /api/v1/push/token: + put: + security: + - Bearer: [] + description: |- + Adds a push token for the given provider to the current user. + If the oldToken is present it will be deleted. + Currently only the provider 'fcm' is supported. + tags: + - push + summary: Adds a push token to the user + operationId: PutUpdatePushTokenRoute + parameters: + - name: Payload + in: body + schema: + $ref: '#/definitions/putUpdatePushTokenPayload' + responses: + "200": + description: OK + "404": + description: PublicHTTPError, type `OLD_PUSH_TOKEN_NOT_FOUND` + schema: + $ref: '#/definitions/publicHttpError' + "409": + description: PublicHTTPError, type `PUSH_TOKEN_ALREADY_EXISTS` + schema: + $ref: '#/definitions/publicHttpError' + /swagger.yml: + get: + description: |- + OpenAPI Specification ver. 2 (fka Swagger) + Returns our handcrafted and validated `swagger.yml`. + produces: + - text/plain + tags: + - common + summary: Get swagger.yml + operationId: GetSwaggerRoute + responses: + "200": + description: OK +definitions: + deleteUserAccountPayload: + type: object + required: + - currentPassword + properties: + currentPassword: + description: Current password of user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + getUserInfoResponse: + type: object + required: + - sub + - updated_at + properties: + email: + description: Email address of user, if available + type: string + format: email + maxLength: 255 + example: user@example.com + scopes: + description: Auth-Scopes of the user, if available + type: array + items: + type: string + enum: + - app + - cms + example: + - app + sub: + description: ID of user + type: string + example: 82ebdfad-c586-4407-a873-4cc1c33d56fc + updated_at: + description: Unix timestamp the user's info was last updated at + type: integer + example: 1591960808 + httpValidationErrorDetail: + type: object + required: + - key + - in + - error + properties: + error: + description: Error describing field validation failure + type: string + in: + description: Indicates how the invalid field was provided + type: string + key: + description: Key of field failing validation + type: string + orderDir: + type: string + enum: + - asc + - desc + postChangePasswordPayload: + type: object + required: + - currentPassword + - newPassword + properties: + currentPassword: + description: Current password of user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + newPassword: + description: New password to set for user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + postForgotPasswordCompletePayload: + type: object + required: + - token + - password + properties: + password: + description: New password to set for user + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + token: + description: Password reset token sent via email + type: string + format: uuid4 + example: ec16f032-3c44-4148-bbcc-45557466fa74 + postForgotPasswordPayload: + type: object + required: + - username + properties: + username: + description: Username to initiate password reset for + type: string + format: email + maxLength: 255 + minLength: 1 + example: user@example.com + postLoginPayload: + type: object + required: + - username + - password + properties: + password: + description: Password of user to authenticate as + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + username: + description: Username of user to authenticate as + type: string + format: email + maxLength: 255 + minLength: 1 + example: user@example.com + postLoginResponse: + type: object + required: + - access_token + - token_type + - expires_in + - refresh_token + properties: + access_token: + description: Access token required for accessing protected API endpoints + type: string + format: uuid4 + example: c1247d8d-0d65-41c4-bc86-ec041d2ac437 + expires_in: + description: Access token expiry in seconds + type: integer + format: int64 + example: 86400 + refresh_token: + description: Refresh token for refreshing the access token once it expires + type: string + format: uuid4 + example: 1dadb3bd-50d8-485d-83a3-6111392568f0 + token_type: + description: Type of access token, will always be `bearer` + type: string + example: bearer + postLogoutPayload: + type: object + properties: + refresh_token: + description: Optional refresh token to delete while logging out + type: string + format: uuid4 + example: 700ebed3-40f7-4211-bc83-a89b22b9875e + postRefreshPayload: + type: object + required: + - refresh_token + properties: + refresh_token: + description: Refresh token to use for retrieving new token set + type: string + format: uuid4 + example: 7503cd8a-c921-4368-a32d-6c1d01d86da9 + postRegisterPayload: + type: object + required: + - username + - password + properties: + password: + description: Password to register with + type: string + maxLength: 500 + minLength: 1 + example: correct horse battery staple + username: + description: Username to register with + type: string + format: email + maxLength: 255 + minLength: 1 + example: user@example.com + publicHttpError: + type: object + required: + - status + - type + - title + properties: + detail: + description: More detailed, human-readable, optional explanation of the error + type: string + example: User is lacking permission to access this resource + status: + description: HTTP status code returned for the error + type: integer + format: int64 + maximum: 599 + minimum: 100 + x-go-name: Code + example: 403 + title: + description: Short, human-readable description of the error + type: string + example: Forbidden + type: + $ref: '#/definitions/publicHttpErrorType' + publicHttpErrorType: + description: Type of error returned, should be used for client-side error handling + type: string + enum: + - generic + - PUSH_TOKEN_ALREADY_EXISTS + - OLD_PUSH_TOKEN_NOT_FOUND + - ZERO_FILE_SIZE + - USER_DEACTIVATED + - INVALID_PASSWORD + - NOT_LOCAL_USER + - TOKEN_NOT_FOUND + - TOKEN_EXPIRED + - USER_ALREADY_EXISTS + - MALFORMED_TOKEN + - LAST_AUTHENTICATED_AT_EXCEEDED + - MISSING_SCOPES + publicHttpValidationError: + type: object + required: + - validationErrors + allOf: + - $ref: '#/definitions/publicHttpError' + properties: + validationErrors: + description: List of errors received while validating payload against schema + type: array + items: + $ref: '#/definitions/httpValidationErrorDetail' + putUpdatePushTokenPayload: + type: object + required: + - newToken + - provider + properties: + newToken: + description: New push token for given provider. + type: string + maxLength: 500 + example: 1c91e550-8167-439c-8021-dee7de2f7e96 + oldToken: + description: Old token that can be deleted if present. + type: string + maxLength: 500 + x-nullable: true + example: 495179de-b771-48f0-aab2-8d23701b0f02 + provider: + description: Identifier of the provider the token is for (eg. "fcm", "apn"). + Currently only "fcm" is supported. + type: string + maxLength: 500 + example: fcm + registerResponse: + type: object + required: + - requiresConfirmation + properties: + requiresConfirmation: + description: Indicates whether the registration process requires email confirmation + type: boolean + example: true +parameters: + registrationTokenParam: + type: string + format: uuid4 + description: Registration token to complete the registration process + name: registrationToken + in: path + required: true +responses: + AuthForbiddenResponse: + description: PublicHTTPError, type `USER_DEACTIVATED`/`NOT_LOCAL_USER` + schema: + $ref: '#/definitions/publicHttpError' + AuthUnauthorizedResponse: + description: PublicHTTPError + schema: + $ref: '#/definitions/publicHttpError' + InvalidPasswordResponse: + description: PublicHTTPValidationError, type `INVALID_PASSWORD` + schema: + $ref: '#/definitions/publicHttpValidationError' + ValidationError: + description: PublicHTTPValidationError + schema: + $ref: '#/definitions/publicHttpValidationError' +securityDefinitions: + Bearer: + description: |- + Access token for application access, **must** include "Bearer " prefix. + Example: `Bearer b4a94a42-3ea2-4af3-9699-8bcbfee6e6d2` + type: apiKey + name: Authorization + in: header + x-keyPrefix: 'Bearer ' + Management: + description: Management secret, used for monitoring and infrastructure related + calls + type: apiKey + name: mgmt-secret + in: query diff --git a/api/templates/server/builder.README b/api/templates/server/builder.README new file mode 100644 index 0000000..2b8db5e --- /dev/null +++ b/api/templates/server/builder.README @@ -0,0 +1,5 @@ +https://raw.githubusercontent.com/go-swagger/go-swagger/master/generator/templates/server/builder.gotmpl +Based on commit in 78f49e7e72c09ff0e39251f002bba729b861e756 +Feb 24, 2020 + +Note that our `builder.gotmpl` is minimal and only used for validating if any routes without a swagger spec exist. \ No newline at end of file diff --git a/api/templates/server/builder.gotmpl b/api/templates/server/builder.gotmpl new file mode 100644 index 0000000..cea3649 --- /dev/null +++ b/api/templates/server/builder.gotmpl @@ -0,0 +1,49 @@ +// Code generated by go-swagger; DO NOT EDIT. + + +{{ if .Copyright -}}// {{ comment .Copyright -}}{{ end }} + + +package types +{{ $package := "types" }} + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +// NewSwaggerSpec creates a new SwaggerSpec instance +func NewSwaggerSpec() *SwaggerSpec { + + spec := &SwaggerSpec{ + Handlers: make(map[string]map[string]bool), + } + + if len(spec.Handlers) == 0 { + spec.initHandlerCache() + } + + return spec +} + +/*SwaggerSpec {{ if .Info }}{{ if .Info.Description }}{{.Info.Description}}{{ else }}the {{ humanize .Name }} API{{ end }}{{ end }} */ +type SwaggerSpec struct { + Handlers map[string]map[string]bool +} + +func ({{.ReceiverName}} *SwaggerSpec) initHandlerCache() { + {{- if .Operations }} + {{.ReceiverName}}.Handlers = make(map[string]map[string]bool) + + // https://swagger.io/specification/v2/ fixed fields: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH + {{.ReceiverName}}.Handlers["GET"] = make(map[string]bool) + {{.ReceiverName}}.Handlers["PUT"] = make(map[string]bool) + {{.ReceiverName}}.Handlers["POST"] = make(map[string]bool) + {{.ReceiverName}}.Handlers["DELETE"] = make(map[string]bool) + {{.ReceiverName}}.Handlers["OPTIONS"] = make(map[string]bool) + {{.ReceiverName}}.Handlers["HEAD"] = make(map[string]bool) + {{.ReceiverName}}.Handlers["PATCH"] = make(map[string]bool) + {{ range .Operations }} + {{.ReceiverName}}.Handlers[{{ printf "%q" (upper .Method) }}][{{ if eq .Path "/" }}""{{ else }}{{ printf "%q" (cleanPath .Path) }}{{ end }}] = true + {{- end }} + {{- end }} +} + diff --git a/api/templates/server/parameter.README b/api/templates/server/parameter.README new file mode 100644 index 0000000..7f49ba1 --- /dev/null +++ b/api/templates/server/parameter.README @@ -0,0 +1,3 @@ +https://github.com/go-swagger/go-swagger/blob/master/generator/templates/server/parameter.gotmpl +Based on commit in b1ca570cfcd2e281d445e0049506f18fa1c2c2cc +Jun 13, 2020 \ No newline at end of file diff --git a/api/templates/server/parameter.gotmpl b/api/templates/server/parameter.gotmpl new file mode 100644 index 0000000..5632427 --- /dev/null +++ b/api/templates/server/parameter.gotmpl @@ -0,0 +1,759 @@ +{{ define "bindprimitiveparam" }}{{/* an empty test definition to test template repo dependencies resolution - DO NOT CHANGE THIS */}} +{{ end }} + +{{ define "bodyvalidator" }} + {{- if .HasModelBodyParams }} + // validate body object{{/* delegate validation to model object */}} + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + } + {{- else if and .HasSimpleBodyParams .HasModelBodyItems }} + + {{- if or .Schema.HasSliceValidations .Schema.Items.HasValidations }} + // validate array of body objects + {{- end }} + {{- if .Schema.HasSliceValidations }} + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + {{ template "sliceparamvalidator" . }} + {{- end }} + + {{- if and .Schema.Items.HasValidations (not (or .Schema.Items.IsInterface .Schema.Items.IsStream)) }} + for {{ .IndexVar }} := range body { + {{- if .Schema.Items.IsNullable }} + if body[{{ .IndexVar }}] == nil { + {{- if .Schema.Items.Required }} + res = append(res, errors.Required({{ .Child.Path }}, {{ printf "%q" .Child.Location }}, body[{{ .IndexVar }}])) + break + {{- else }} + continue + {{- end }} + } + {{- end }} + if err := body[{{ .IndexVar }}].Validate(route.Formats); err != nil { + res = append(res, err) + break + } + } + + {{- if not .Schema.HasSliceValidations }} + if len(res) == 0 { + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + } + {{- end }} + {{- else }} + // no validation for items in this slice + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + {{- end }} + + {{- else if and .HasSimpleBodyParams .HasModelBodyMap }} + + {{- if and .Schema.HasValidations (not (or .Schema.AdditionalProperties.IsInterface .Schema.AdditionalProperties.IsStream)) }} + // validate map of body objects + for {{ .KeyVar }} := range body { + {{- if .Schema.AdditionalProperties.Required }} + if err := validate.Required({{ if .Child.Path }}{{ .Child.Path }}{{ else }}""{{ end }}, {{ printf "%q" .Child.Location }}, {{ if not .IsAnonymous }}{{ .Schema.GoType }}({{ end }}body[{{ .KeyVar }}]{{ if not .IsAnonymous }}){{ end }}); err != nil { + return err + } + {{- end }} + {{- if and .Schema.AdditionalProperties.IsNullable (not .IsMapNullOverride) }} + if body[{{ .KeyVar }}] == nil { + {{- if .Schema.AdditionalProperties.Required }} + res = append(res, errors.Required({{ .Path }}, {{ printf "%q" .Location }}, body[{{ .KeyVar }}])) + break + {{- else }} + continue + {{- end }} + } + {{- end }} + if val , ok :=body[{{ .KeyVar }}]; ok { + {{- if and .IsNullable (not .IsMapNullOverride) }} + if val != nil { + {{- end }} + if err := val.Validate(route.Formats); err != nil { + res = append(res, err) + break + } + {{- if and .IsNullable (not .IsMapNullOverride) }} + } + {{- end }} + } + } + + if len(res) == 0 { + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + } + {{- else }} + // no validation for this map + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + {{- end }} + {{- else if .HasSimpleBodyParams }} + {{- if and (not .IsArray) (not .IsMap) .Schema.HasValidations }} + // validate inline body + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + if err := {{ .ReceiverName }}.validate{{ pascalize .ID }}Body(route.Formats); err != nil { + res = append(res, err) + } + {{- else if and (or .IsArray .IsMap) .Schema.HasValidations }} + // validate inline body {{ if .IsArray }}array{{ else }}map{{ end }} + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + if err := {{ .ReceiverName }}.validate{{ pascalize .ID }}Body(route.Formats); err != nil { + res = append(res, err) + } + {{- else }} + // no validation required on inline body + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + {{- end}} + {{- else }} + {{- if .IsInterface }} + // no validation on generic interface + {{ .ReceiverName }}.{{ pascalize .Name }} = {{ if and (not .Schema.IsBaseType) .IsNullable }}&{{ end }}body + {{- end }} + {{- end }} +{{- end }} + +{{ define "sliceparamvalidator"}} +{{ if or .MinItems .MaxItems }} + {{ camelize .Name }}Size := int64(len({{ if and (not .IsArray) (not .HasDiscriminator) (not .IsInterface) (not .IsStream) .IsNullable }}*{{ end }}{{ if and .Child (not (hasPrefix .ValueExpression "o.")) }}{{ .Child.ValueExpression }}C{{ else }}{{ .ValueExpression }}{{ end }})) +{{ end }} +{{ if .MinItems }} +// {{ .ItemsDepth }}minItems: {{ .MinItems }} +if err := validate.MinItems({{ .Path }}, {{ printf "%q" .Location }}, {{ camelize .Name }}Size, {{ .MinItems }}); err != nil { + return err +} +{{ end }} +{{ if .MaxItems }} +// {{ .ItemsDepth }}maxItems: {{ .MaxItems }} +if err := validate.MaxItems({{ .Path }}, {{ printf "%q" .Location }}, {{ camelize .Name }}Size, {{.MaxItems}}); err != nil { + return err +} +{{ end }} +{{ if .UniqueItems }} +// {{ .ItemsDepth }}uniqueItems: true +if err := validate.UniqueItems({{ .Path }}, {{ printf "%q" .Location }}, {{ if and (not .IsArray) (not .HasDiscriminator) (not .IsInterface) (not .IsStream) .IsNullable }}*{{ end }}{{ if and .Child (not ( hasPrefix .ValueExpression "o." )) }}{{ .Child.ValueExpression }}C{{ else }}{{ .ValueExpression }}{{ end }}); err != nil { + return err +} +{{ end }} +{{ if .Enum }} +// {{ .ItemsDepth }}Enum: {{ .Enum }} +if err := validate.EnumCase( + {{- .Path }}, {{ printf "%q" .Location }}, + {{- if and (not .IsArray) (not .HasDiscriminator) (not .IsInterface) (not .IsStream) .IsNullable }}*{{ end -}} + {{- if .Child -}} + {{- if not ( hasPrefix .ValueExpression "o." ) -}} + {{- .Child.ValueExpression }}C{{- if .IsCustomFormatter }}.String(){{ end -}} + {{- else -}} + {{- .ValueExpression -}}{{- if .Child.IsCustomFormatter }}.String(){{ end -}} + {{- end -}} + {{- end -}}, + {{- printf "%#v" .Enum -}}, {{ if .IsEnumCI }}false{{ else }}true{{ end }}); err != nil { + return err + } +{{ end }} +{{/* BEGIN CUSTOM BY AAA */}} +{{- if .IsArray }}{{/* slice validations */}} + for {{ if .Child.NeedsIndex }}{{ .IndexVar }}{{ else }}_{{ end }}, {{ varname .Child.ValueExpression }}V := range {{ varname .Child.ValueExpression }}C { + {{- if .Child.IsArray }}{{/* recursive resolution of arrays in params */}} + {{- if not .Child.SkipParse }} + // {{ .Child.ItemsDepth }}CollectionFormat: {{ .Child.CollectionFormat }} + {{- end }} + {{ .Child.Child.ValueExpression }}C := {{ if .Child.SkipParse }}{{ varname .Child.ValueExpression }}V{{ else }}swag.SplitByFormat({{ varname .Child.ValueExpression }}V, {{ printf "%q" .Child.CollectionFormat }}){{ end }} + {{- if .Child.HasSliceValidations }} + {{- template "sliceparamvalidator" .Child }} + {{- end }} + {{- else if .Child.IsMap }}{{/* simple map in items (possible with body params)*/}} + {{ .Child.Child.ValueExpression }}C := {{ varname .Child.ValueExpression }}V + {{- template "mapparamvalidator" .Child }} + {{- else }}{{/* non-array && non-map type in items */}} + {{- if and .Child.IsNullable (not .IsMapNullOverride) }} + if {{ varname .Child.ValueExpression }}V == nil { + {{- if .Child.Required }} + return errors.Required({{ .Child.Path }}, {{ printf "%q" .Child.Location }}, {{ varname .Child.ValueExpression }}V) + {{- else }} + continue + {{- end }} + } + {{- end }} + {{- template "childvalidator" .Child }} + {{- end }} + } +{{ end }} +{{/* END CUSTOM BY AAA */}} +{{ end }} + +{{- define "childvalidator" }} + {{- if .Converter }} + {{- if ne .SwaggerFormat "" }} + // {{ .ItemsDepth }}Format: {{ printf "%q" .SwaggerFormat }} + {{- end }} + {{ varname .ValueExpression }}, err := {{ .Converter }}({{ varname .ValueExpression }}V) + if err != nil { + return errors.InvalidType({{ .Path }}, {{ printf "%q" .Location }}, "{{ .GoType }}", {{ varname .ValueExpression }}) + } + {{- else if and .IsCustomFormatter (not .SkipParse) }}{{/* parsing is skipped for simple body items */}} + // {{ .ItemsDepth }}Format: {{ printf "%q" .SwaggerFormat }} + value, err := formats.Parse({{ printf "%q" .SwaggerFormat }},{{ varname .ValueExpression }}V) + if err != nil { + return errors.InvalidType({{ .Path }}, {{ printf "%q" .Location }}, "{{ .GoType }}", value) + } + {{ varname .ValueExpression }} := *(value.(*{{.GoType}})) + {{- else if and .IsComplexObject .HasValidations }}{{/* dedicated to nested body params */}} + {{ varname .ValueExpression }} := {{ varname .ValueExpression }}V + if err := {{ .ValueExpression }}.Validate(formats) ; err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName({{ .Path }}) + } + return err + } + {{- else }} + {{ varname .ValueExpression }} := {{ varname .ValueExpression }}V + {{- end }} + {{ template "propertyparamvalidator" . }} +{{- end }} + +{{- define "mapparamvalidator" }} + {{- if and .Child.HasValidations (not (or .Child.IsInterface .Child.IsStream)) }} + + // validations for map + {{- else }} + + // map has no validations: copying all elements + {{- end }} + {{ varname .Child.ValueExpression }}R := make({{ .GoType }},len({{ .Child.ValueExpression }}C)) + for {{ .KeyVar }}, {{ .Child.ValueExpression }}V := range {{ .Child.ValueExpression}}C { + {{- if .Child.IsArray }} + {{ .Child.Child.ValueExpression }}C := {{ varname .Child.ValueExpression }}V + {{- if .Child.HasSliceValidations }} + {{- template "sliceparamvalidator" .Child }} + {{- end }} + {{- template "sliceparambinder" .Child }} + {{- else if .Child.IsMap }} + {{ .Child.Child.ValueExpression }}C := {{ varname .Child.ValueExpression }}V + {{ template "mapparamvalidator" .Child }} + {{- else }} + {{- if and .Child.IsNullable }} + if {{ varname .Child.ValueExpression }}V == nil { + {{- if .Child.Required }} + return errors.Required({{ .Child.Path }}, {{ printf "%q" .Child.Location }}, {{ varname .Child.ValueExpression }}V) + {{- else }} + continue + {{- end }} + } + {{- end }} + {{- template "childvalidator" .Child }} + {{- end }} + {{ varname .Child.ValueExpression }}R[{{.KeyVar}}] = {{ varname .Child.ValueExpression }}{{ if or .Child.IsArray .Child.IsMap}}IR{{end}} + } +{{- end }} + +{{- define "propertyparamvalidator" }} + {{/* BEGIN CUSTOM BY AAA */}} + {{- if .IsNullable }} + // Required: {{ if .Required }}true{{ else }}false{{ end }} + if {{.ValueExpression}} == nil { + {{- if .Required }} + return errors.Required({{ printf "%q" (camelize .Name) }}, {{ printf "%q" .Location }}, "") + {{- else }} + return nil + {{- end }} + } + {{- end }} + {{- if .IsPrimitive }} + {{ template "validationPrimitive" . }} + {{- end }} + {{- if and .IsCustomFormatter (not .IsStream) (not .IsBase64) }} +if err := validate.FormatOf({{.Path}}, "{{.Location}}", "{{.SwaggerFormat}}", {{ if .IsNullable }}(*{{ end }}{{.ValueExpression}}{{ if .IsNullable }}){{ end }}{{ if .IsCustomFormatter }}.String(){{ end }}, formats); err != nil { + return err +} + {{- end }} + {{- if .IsArray }}{{/* slice validations */}} + {{ varname .Child.ValueExpression }}C := {{ .ValueExpression }} + {{/* END CUSTOM BY AAA */}} + {{ template "sliceparamvalidator" . }} + {{- else if .IsMap }} + {{ .Child.ValueExpression }}C := {{ varname .Child.ValueExpression }}V + {{ template "mapparamvalidator" . }} + {{- end }} +{{- end }} + +{{ define "sliceparambinder" }} +var {{ varname .Child.ValueExpression }}R {{ .GoType }} +for {{ if .Child.NeedsIndex }}{{ .IndexVar }}{{ else }}_{{ end }}, {{ varname .Child.ValueExpression }}V := range {{ varname .Child.ValueExpression }}C { + {{- if .Child.IsArray }}{{/* recursive resolution of arrays in params */}} + {{- if not .Child.SkipParse }} + // {{ .Child.ItemsDepth }}CollectionFormat: {{ .Child.CollectionFormat }} + {{- end }} + {{ .Child.Child.ValueExpression }}C := {{ if .Child.SkipParse }}{{ varname .Child.ValueExpression }}V{{ else }}swag.SplitByFormat({{ varname .Child.ValueExpression }}V, {{ printf "%q" .Child.CollectionFormat }}){{ end }} + {{- if .Child.HasSliceValidations }} + {{- template "sliceparamvalidator" .Child }} + {{- end }} + if len({{ varname .Child.Child.ValueExpression }}C) > 0 { + {{ template "sliceparambinder" .Child }} + {{ varname .Child.ValueExpression }}R = append({{ varname .Child.ValueExpression }}R, {{ varname .Child.ValueExpression }}{{ if or .Child.IsArray .Child.IsMap }}IR{{end}}) + } + {{- else if .Child.IsMap }}{{/* simple map in items (possible with body params)*/}} + {{ .Child.Child.ValueExpression }}C := {{ varname .Child.ValueExpression }}V + {{- template "mapparamvalidator" .Child }} + {{ varname .Child.ValueExpression }}R = append({{ varname .Child.ValueExpression }}R, {{ varname .Child.ValueExpression }}{{ if or .Child.IsArray .Child.IsMap }}IR{{end}}) + {{- else }}{{/* non-array && non-map type in items */}} + {{- if and .Child.IsNullable (not .IsMapNullOverride) }} + if {{ varname .Child.ValueExpression }}V == nil { + {{- if .Child.Required }} + return errors.Required({{ .Child.Path }}, {{ printf "%q" .Child.Location }}, {{ varname .Child.ValueExpression }}V) + {{- else }} + continue + {{- end }} + } + {{- end }} + {{- template "childvalidator" .Child }} + {{ varname .Child.ValueExpression }}R = append({{ varname .Child.ValueExpression }}R, {{ varname .Child.ValueExpression }}{{ if or .Child.IsArray .Child.IsMap }}IR{{end}}) + {{- end }} +} +{{ end }} + +{{/* BEGIN CUSTOM BY AAA */}} +{{- define "customsliceparamvalidator" }} + {{- template "sliceparamvalidator" . -}} + for _, {{ varname .Child.ValueExpression }}V := range {{ varname .Child.ValueExpression }}C { + {{- if .Child.IsArray }}{{/* recursive resolution of arrays in params */}} + {{ varname .Child.ValueExpression }}IC := {{ varname .Child.ValueExpression }}V + {{- template "customsliceparamvalidator" .Child }} + {{- else if .Child.IsCustomFormatter }} + // Format: {{ .Child.SwaggerFormat }} + if err := validate.FormatOf({{ .Path }}, {{ printf "%q" .Location }}, {{ printf "%q" .SwaggerFormat }}, {{ varname .Child.ValueExpression }}V.String(), formats); err != nil { + res = append(res, errors.InvalidType({{ .Path }}, {{ printf "%q" .Location }}, {{ printf "%q" .GoType }}, {{ varname .Child.ValueExpression }}V)) + } + {{- end -}} + } +{{- end }} +{{/* END CUSTOM BY AAA */}} +// Code generated by go-swagger; DO NOT EDIT. + + +{{ if .Copyright -}}// {{ comment .Copyright -}}{{ end }} + + +package {{ .Package }} + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/security" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" + + {{ imports .DefaultImports }} + {{ imports .Imports }} +) + +// New{{ pascalize .Name }}Params creates a new {{ pascalize .Name }}Params object +{{ if .Params.HasSomeDefaults -}} +// with the default values initialized.{{ else -}} +// no default values defined in spec. +{{- end }} +func New{{ pascalize .Name }}Params() {{ pascalize .Name }}Params { +{{ if .Params.HasSomeDefaults }} + var ( + // initialize parameters with default values + {{ range .Params }} + {{ if .HasDefault -}} + {{ if not .IsFileParam }}{{ varname .ID}}Default = + {{- if and .IsPrimitive .IsCustomFormatter (not (stringContains .Zero "(\"" )) }}{{ .Zero }}{{/* strfmt type initializer requires UnmarshalText(), e.g. Date, Datetime, Duration */}} + {{- else if and .IsPrimitive .IsCustomFormatter (stringContains .Zero "(\"" ) }}{{.GoType}}({{- printf "%#v" .Default }}){{/* strfmt type initializer takes string */}} + {{- else if and .IsPrimitive (not .IsCustomFormatter) -}}{{.GoType}}({{- printf "%#v" .Default }}){{/* regular go primitive type initializer */}} + {{- else if .IsArray -}}{{- /* Do not initialize from possible defaults in nested arrays */ -}} + {{- if and .Child.IsPrimitive .Child.IsCustomFormatter }}{{ .Zero }}{{/* initialization strategy with UnmarshalText() */}} + {{- else if .Child.IsArray -}}{{ .Zero }}{{/* initialization strategy with json.Unmarshal() */}} + {{- else if and .Child.IsPrimitive (not .Child.IsCustomFormatter) -}}{{.GoType}}{{- arrayInitializer .Default }}{{/* regular go primitive type initializer: simple slice initializer */}} + {{- else }}{{ printf "%#v" .Default }}{{/* all other cases (e.g. schema) [should not occur] */}} + {{- end }} + {{- else }}{{ printf "%#v" .Default }}{{/* case .Schema */}} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + ) + +{{ range .Params }}{{ if .HasDefault -}}{{- /* carry on UnmarshalText initialization strategy */ -}} + {{ if and .IsPrimitive .IsCustomFormatter (not (stringContains .Zero "(\"")) }}{{ varname .ID}}Default.UnmarshalText([]byte({{ printf "%q" .Default }})) + {{ else if .IsArray -}} + {{ if or ( and .Child.IsPrimitive .Child.IsCustomFormatter ) .Child.IsArray -}} + if err := json.Unmarshal([]byte(`{{printf "%s" (json .Default)}}`), &{{ varname .ID }}Default); err != nil { + // panics if specification is invalid + msg := fmt.Sprintf("invalid default value for parameter {{ varname .ID }}: %v",err) + panic(msg) + } + {{ end -}} + {{- end }} + {{ end -}} +{{- end }} +{{ end }} + return {{ pascalize .Name }}Params{ {{ range .Params }}{{ if .HasDefault }} + {{ pascalize .ID}}: {{ if and (not .IsArray) (not .HasDiscriminator) (not .IsInterface) (not .IsStream) .IsNullable }}&{{ end }}{{ varname .ID }}Default, + {{ end }}{{ end }} } +} + +// {{ pascalize .Name }}Params contains all the bound params for the {{ humanize .Name }} operation +// typically these are obtained from a http.Request +// +// swagger:parameters {{ .Name }} +type {{ pascalize .Name }}Params struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + {{ range .Params }}/*{{ if .Description }}{{ blockcomment .Description }}{{ end }}{{ if .Required }} + Required: true{{ end }}{{ if .Maximum }} + Maximum: {{ if .ExclusiveMaximum }}< {{ end }}{{ .Maximum }}{{ end }}{{ if .Minimum }} + Minimum: {{ if .ExclusiveMinimum }}> {{ end }}{{ .Minimum }}{{ end }}{{ if .MultipleOf }} + Multiple Of: {{ .MultipleOf }}{{ end }}{{ if .MaxLength }} + Max Length: {{ .MaxLength }}{{ end }}{{ if .MinLength }} + Min Length: {{ .MinLength }}{{ end }}{{ if .Pattern }} + Pattern: {{ .Pattern }}{{ end }}{{ if .MaxItems }} + Max Items: {{ .MaxItems }}{{ end }}{{ if .MinItems }} + Min Items: {{ .MinItems }}{{ end }}{{ if .UniqueItems }} + Unique: true{{ end }}{{ if .Location }} + In: {{ .Location }}{{ end }}{{ if .CollectionFormat }} + Collection Format: {{ .CollectionFormat }}{{ end }}{{ if .HasDefault }} + Default: {{ printf "%#v" .Default }}{{ end }} + */ + {{ if not .Schema }}{{ pascalize .ID }} {{ if and (not .IsArray) (not .HasDiscriminator) (not .IsInterface) (not .IsStream) .IsNullable }}*{{ end }}{{.GoType}}{{ else }}{{ pascalize .Name }} {{ if and (not .Schema.IsBaseType) .IsNullable (not .Schema.IsStream) }}*{{ end }}{{.GoType}}{{ end }}{{/* BEGIN CUSTOM BY AAA */}}{{ if .IsQueryParam }}`query:"{{ .Name }}"`{{ else if .IsPathParam }}`param:"{{ .Name }}"`{{ else if .IsFormParam }}`form:"{{ .Name }}"`{{ end }}{{/* END CUSTOM BY AAA */}} + {{ end}} +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with New{{ pascalize .Name }}Params() beforehand. +func ({{ .ReceiverName }} *{{ pascalize .Name }}Params) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + {{ .ReceiverName }}.HTTPRequest = r + + {{ if .HasQueryParams }}qs := runtime.Values(r.URL.Query()){{ end }} + + {{ if .HasFormParams }}if err := r.ParseMultipartForm(32 << 20); err != nil { + if err != http.ErrNotMultipart { + return errors.New(400,"%v",err) + } else if err := r.ParseForm(); err != nil { + return errors.New(400,"%v",err) + } + }{{ if .HasFormValueParams }} + fds := runtime.Values(r.Form) + {{ end }}{{ end }} + + {{ range .Params }} + {{ if not .IsArray -}} + {{ if .IsQueryParam }}q{{ pascalize .Name }}, qhk{{ pascalize .Name }}, _ := qs.GetOK({{ .Path }}) + if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(q{{ pascalize .Name }}, qhk{{ pascalize .Name }}, route.Formats); err != nil { + res = append(res, err) + } + {{ else if .IsPathParam }}r{{ pascalize .Name }}, rhk{{ pascalize .Name }}, _ := route.Params.GetOK({{ .Path }}) + if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(r{{ pascalize .Name }}, rhk{{ pascalize .Name }}, route.Formats); err != nil { + res = append(res, err) + } + {{ else if .IsHeaderParam }}if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(r.Header[http.CanonicalHeaderKey({{ .Path }})], true, route.Formats); err != nil { + res = append(res, err) + } + {{ else if .IsFormParam -}} + {{if .IsFileParam }}{{ camelize .Name }}, {{ camelize .Name }}Header, err := r.FormFile({{ .Path }}) + if err != nil {{ if .IsNullable }}&& err != http.ErrMissingFile{{ end }}{ + res = append(res, errors.New(400, "reading file %q failed: %v", {{ printf "%q" (camelize .Name) }}, err)) + {{ if .IsNullable }}} else if err == http.ErrMissingFile { + // no-op for missing but optional file parameter + {{ end }}} else if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}({{ camelize .Name }}, {{ camelize .Name }}Header); err != nil { + {{ if .Required }}// Required: true{{ printf "\n" }}{{end }}res = append(res, err) + } else { + {{ .ReceiverName }}.{{ pascalize .Name }} = &runtime.File{Data: {{ camelize .Name }}, Header: {{ camelize .Name }}Header} + } + {{ else }}fd{{ pascalize .Name }}, fdhk{{ pascalize .Name }}, _ := fds.GetOK({{ .Path }}) + if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(fd{{ pascalize .Name }}, fdhk{{ pascalize .Name }}, route.Formats); err != nil { + res = append(res, err) + } + {{ end }}{{/* end .FileParam */}} + {{ end }}{{/* end not .Array */}} + {{ else if .IsArray -}} + {{ if .IsQueryParam }}q{{ pascalize .Name }}, qhk{{ pascalize .Name }}, _ := qs.GetOK({{ .Path }}) + if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(q{{ pascalize .Name }}, qhk{{ pascalize .Name }}, route.Formats); err != nil { + res = append(res, err) + } + {{ else if .IsPathParam }}r{{ pascalize .Name }}, rhk{{ pascalize .Name }}, _ := route.Params.GetOK({{ .Path }}) + if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(r{{ pascalize .Name }}, rhk{{ pascalize .Name }}, route.Formats); err != nil { + res = append(res, err) + } + {{ else if .IsHeaderParam }}if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(r.Header[http.CanonicalHeaderKey({{ .Path }})], true, route.Formats); err != nil { + res = append(res, err) + } + {{ else if and .IsFormParam }}fd{{ pascalize .Name }}, fdhk{{ pascalize .Name }}, _ := fds.GetOK({{ .Path }}) + if err := {{ .ReceiverName }}.bind{{ pascalize .ID }}(fd{{ pascalize .Name }}, fdhk{{ pascalize .Name }}, route.Formats); err != nil { + res = append(res, err) + } + {{ end }}{{ end }} + {{ if and .IsBodyParam .Schema -}} + if runtime.HasBody(r) { + {{- if .Schema.IsStream }} + {{ .ReceiverName }}.{{ pascalize .Name }} = r.Body + {{- else }} + defer r.Body.Close() + {{- if and .Schema.IsBaseType .Schema.IsExported }} + body, err := {{ toPackageName .ModelsPackage }}.Unmarshal{{ dropPackage .GoType }}{{ if .IsArray }}Slice{{ end }}(r.Body, route.Consumer) + if err != nil { + {{- if .Required }} + if err == io.EOF { + err = errors.Required({{ .Path }}, {{ printf "%q" .Location }}, "") + } + {{- end }} + res = append(res, err) + {{- else }} + var body {{ .GoType }} + if err := route.Consumer.Consume(r.Body, &body); err != nil { + {{- if .Required }} + if err == io.EOF { + res = append(res, errors.Required({{ printf "%q" (camelize .Name) }}, {{ printf "%q" .Location }}, "")) + } else { + {{- end }} + res = append(res, errors.NewParseError({{ printf "%q" (camelize .Name) }}, {{ printf "%q" .Location }}, "", err)) + {{- if .Required }} + } + {{- end -}} + {{- end }} + } else { + {{- template "bodyvalidator" . }} + } + {{- end }} + } + {{- if .Required }} else { + res = append(res, errors.Required({{ printf "%q" (camelize .Name) }}, {{ printf "%q" .Location }}, "")) + } + {{- end }} + {{- end }} + {{- end }} + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +{{/* BEGIN CUSTOM BY AAA */}} +func ({{ .ReceiverName }} *{{ pascalize .Name }}Params) Validate(formats strfmt.Registry) error { + var res []error + {{ range .Params }} + // {{ .Name }} + // Required: {{ .Required }} + {{ if .IsQueryParam }}// AllowEmptyValue: {{ .AllowEmptyValue }}{{ end -}}{{ if .IsPathParam }}// Parameter is provided by construction from the route{{ end -}} + {{/* file validations */}} + {{- if .IsFileParam }} + {{- if or .MinLength .MaxLength }} + size, _ := {{ .ReceiverName }}.{{ .ID }}.Seek(0, io.SeekEnd) + file.Seek(0, io.SeekStart) + {{- end }} + {{- if .MinLength}} + if size < {{.MinLength}} { + res = append(res, errors.ExceedsMinimum({{ .Path }}, {{ printf "%q" .Location }}, {{ .MinLength }}, false)) + } + {{- end }} + {{- if .MaxLength}} + if size > {{.MaxLength}} { + res = append(res, errors.ExceedsMaximum({{ .Path }}, {{ printf "%q" .Location }}, {{ .MaxLength }}, false)) + } + {{- end }} + {{/* body validations, currently not used */}} + {{- else if .IsBodyParam }} + // body is validated in endpoint + //if err := {{ .ReceiverName }}.{{ .ID }}.Validate(formats); err != nil { + // res = append(res, err) + //} + {{/* validate for arrays */}} + {{- else if .IsArray }} + {{- if .HasSliceValidations }} + if err := {{ .ReceiverName }}.validate{{ pascalize .ID }}(formats); err != nil { + res = append(res, err) + } + {{- else if or .Child.IsCustomFormatter .Child.IsArray }} + {{ varname .Child.ValueExpression }}C := {{ .ValueExpression }} + {{- template "customsliceparamvalidator" . }} + {{- end }} + {{/* path and query validaionts */}} + {{- else if not .IsBodyParam }} + {{/* if required check if exists */}} + {{- if and (not .IsPathParam) .Required (not .AllowEmptyValue) -}} + if err := validate.Required({{ .Path }}, {{ printf "%q" .Location }}, {{ .ReceiverName }}.{{ .ID }}); err != nil { + res = append(res, err) + } + {{- end }} + {{/* if a validation method exists for the property exists, call it */}} + {{- if .HasValidations }} + if err := {{ .ReceiverName }}.validate{{ pascalize .ID }}(formats); err != nil { + res = append(res, err) + } + {{- else if .IsCustomFormatter }}// Format: {{ .SwaggerFormat }} + if err := validate.FormatOf({{ .Path }}, {{ printf "%q" .Location }}, {{ printf "%q" .SwaggerFormat }}, {{ .ReceiverName }}.{{ .ID }}, formats); err != nil { + res = append(res, errors.InvalidType({{ .Path }}, {{ printf "%q" .Location }}, {{ printf "%q" .GoType }}, {{ .ReceiverName }}.{{ .ID }})) + } + {{- end }} + {{/* in case of missing validation a TODO comment is added */}} + {{- else }} + // TODO add case in template + {{- end }} + {{- end }} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} +{{/* END CUSTOM BY AAA */}} + +{{- $className := (pascalize .Name) }} +{{ range .Params }} + {{- if .IsFileParam }} +// bind{{ pascalize .ID }} binds file parameter {{ .ID }}. +// +// The only supported validations on files are MinLength and MaxLength +func ({{ .ReceiverName }} *{{ $className }}Params) bind{{ pascalize .ID }}(file multipart.File, header *multipart.FileHeader) error { + {{- if or .MinLength .MaxLength }} + size, _ := file.Seek(0, io.SeekEnd) + file.Seek(0, io.SeekStart) + {{- end }} + {{- if .MinLength}} + if size < {{.MinLength}} { + return errors.ExceedsMinimum({{ .Path }}, {{ printf "%q" .Location }}, {{ .MinLength }}, false) + } + {{- end }} + {{- if .MaxLength}} + if size > {{.MaxLength}} { + return errors.ExceedsMaximum({{ .Path }}, {{ printf "%q" .Location }}, {{ .MaxLength }}, false) + } + {{- end }} + return nil +} + {{- else if not .IsBodyParam }} + {{- if or .IsPrimitive .IsCustomFormatter }} +// bind{{ pascalize .ID }} binds and validates parameter {{ .ID }} from {{ .Location }}. +func ({{ .ReceiverName }} *{{ $className }}Params) bind{{ pascalize .ID }}(rawData []string, hasKey bool, formats strfmt.Registry) error { + {{- if and (not .IsPathParam) .Required }} + if !hasKey { + return errors.Required({{ .Path }}, {{ printf "%q" .Location }}, rawData) + } + {{- end }} + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: {{ .Required }} + {{ if .IsQueryParam }}// AllowEmptyValue: {{ .AllowEmptyValue }}{{ end }}{{ if .IsPathParam }}// Parameter is provided by construction from the route{{ end }} + {{ if and (not .IsPathParam) .Required (not .AllowEmptyValue) -}} + if err := validate.RequiredString({{ .Path }}, {{ printf "%q" .Location }}, raw); err != nil { + return err + } + {{ else if and ( not .IsPathParam ) (or (not .Required) .AllowEmptyValue) -}} + if raw == "" { // empty values pass all other validations{{ if .HasDefault }} + // Default values have been previously initialized by New{{ $className }}Params(){{ end }} + return nil + } + {{ end }} + {{ if .Converter }}value, err := {{ .Converter }}(raw) + if err != nil { + return errors.InvalidType({{ .Path }}, {{ printf "%q" .Location }}, {{ printf "%q" .GoType }}, raw) + } + {{ .ValueExpression }} = {{ if .IsNullable }}&{{ end }}value + {{ else if .IsCustomFormatter }}// Format: {{ .SwaggerFormat }} + value, err := formats.Parse({{ printf "%q" .SwaggerFormat }}, raw) + if err != nil { + return errors.InvalidType({{ .Path }}, {{ printf "%q" .Location }}, {{ printf "%q" .GoType }}, raw) + } + {{ .ValueExpression }} = {{ if or .IsArray .HasDiscriminator .IsFileParam .IsStream (not .IsNullable) }}*{{ end }}(value.(*{{ .GoType }})) + {{else}}{{ .ValueExpression }} = {{ if .IsNullable }}&{{ end }}raw + {{ end }} + {{if .HasValidations }}if err := {{ .ReceiverName }}.validate{{ pascalize .ID }}(formats); err != nil { + return err + } + {{ end }} + return nil +} + {{- else if .IsArray }} +// bind{{ pascalize .ID }} binds and validates array parameter {{ .ID }} from {{ .Location }}. +// +// Arrays are parsed according to CollectionFormat: "{{ .CollectionFormat }}" (defaults to "csv" when empty). +func ({{ .ReceiverName }} *{{ $className }}Params) bind{{ pascalize .ID }}(rawData []string, hasKey bool, formats strfmt.Registry) error { + {{if .Required }}if !hasKey { + return errors.Required({{ .Path }}, {{ printf "%q" .Location }}, rawData) + } + {{ end }} + {{ if eq .CollectionFormat "multi" -}} + // CollectionFormat: {{ .CollectionFormat }} + {{ varname .Child.ValueExpression }}C := rawData + {{ else -}} + var qv{{ pascalize .Name }} string + if len(rawData) > 0 { + qv{{ pascalize .Name }} = rawData[len(rawData) - 1] + } + + // CollectionFormat: {{ .CollectionFormat }} + {{ varname .Child.ValueExpression }}C := swag.SplitByFormat(qv{{ pascalize .Name }}, {{ printf "%q" .CollectionFormat }}){{ end }} + {{if and .Required (not .AllowEmptyValue) }} + if len({{ varname .Child.ValueExpression }}C) == 0 { + return errors.Required({{ .Path }}, {{ printf "%q" .Location }}, {{ varname .Child.ValueExpression }}C) + } + {{ else }}if len({{ varname .Child.ValueExpression }}C) == 0 { {{ if .HasDefault }} + // Default values have been previously initialized by New{{ $className }}Params(){{ end }} + return nil + } {{- end }} + {{ template "sliceparambinder" . }} + {{ .ValueExpression }} = {{ varname .Child.ValueExpression }}R + {{- if .HasSliceValidations }} + if err := {{ .ReceiverName }}.validate{{ pascalize .ID }}(formats); err != nil { + return err + } + {{- end }} + + return nil +} + {{ end }} + {{ if or (and (not .IsArray) .HasValidations) (and .IsArray .HasSliceValidations) }} +// validate{{ pascalize .ID }} carries on validations for parameter {{ .ID }} +func ({{ .ReceiverName }} *{{ $className }}Params) validate{{ pascalize .ID }}(formats strfmt.Registry) error { + {{ template "propertyparamvalidator" . }} + return nil +} + {{ end }} + {{- else if .IsBodyParam }}{{/* validation method for inline body parameters with validations */}} + {{- if and .HasSimpleBodyParams (not .HasModelBodyItems) (not .HasModelBodyMap) }} + {{- if .Schema.HasValidations }} +// validate{{ pascalize .ID }}Body validates an inline body parameter +func ({{ .ReceiverName }} *{{ $className }}Params) validate{{ pascalize .ID }}Body(formats strfmt.Registry) error { + {{- if .IsArray }} + {{- if .HasSliceValidations }} + {{- template "sliceparamvalidator" . }} + {{- end }} + {{- if .Child.HasValidations }} + {{ varname .Child.ValueExpression }}C := {{ .ValueExpression }} + {{ template "sliceparambinder" . }} + {{ .ValueExpression }} = {{ varname .Child.ValueExpression }}R + {{- end }} + {{- else if .IsMap }} + {{ varname .Child.ValueExpression }}C := {{ .ValueExpression }} + {{ template "mapparamvalidator" . }} + {{ .ValueExpression }} = {{ varname .Child.ValueExpression }}R + {{- else }} + {{ template "propertyparamvalidator" . }} + {{- end }} + return nil +} + {{- end }} + {{- end }} + {{- end }} +{{ end }} \ No newline at end of file diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..f7e93f4 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,13 @@ +# `/assets` + +Other assets to go along with your repository (images, logos, etc). + +https://github.com/golang-standards/project-layout/tree/master/assets + +### all about apps specific requirement regarding `/assets/mnt` + +`/assets/mnt` is special, as this is typically our PV rw mountpoint in our infrastucture. + +**Do not check-in files there** (it's also `.gitignore`d)! + +Instead, use this path as your default place to write user generated content that needs to be persisted. This folder will get **shadowed** by the PV mount in our infrastructure and will be available by all replicas through NFS. \ No newline at end of file diff --git a/assets/mnt/.gitkeep b/assets/mnt/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 0000000..ee5affb --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,11 @@ +# `/cmd` + +Main applications for this project. + +Don't put a lot of code in the application directory. If you think the code can be imported and used in other projects, then it should live in the `/pkg` directory. If the code is not reusable or if you don't want others to reuse it, put that code in the `/internal` directory. You'll be surprised what others will do, so be explicit about your intentions! + +We manage our applications via cobra ([`cobra-cli`](https://github.com/spf13/cobra-cli) is installed within the `Dockerfile` within the development stage, `/cmd` consumes the core [`cobra`](https://github.com/spf13/cobra) library), see: +* https://github.com/spf13/cobra#getting-started +* https://github.com/spf13/cobra-cli/blob/main/README.md#add-commands-to-a-project + +Also see https://github.com/golang-standards/project-layout/tree/master/cmd \ No newline at end of file diff --git a/cmd/db/db.go b/cmd/db/db.go new file mode 100644 index 0000000..88cd243 --- /dev/null +++ b/cmd/db/db.go @@ -0,0 +1,13 @@ +package db + +import ( + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + return command.NewSubcommandGroup("db", + newMigrate(), + newSeed(), + ) +} diff --git a/cmd/db/migrate.go b/cmd/db/migrate.go new file mode 100644 index 0000000..4847ddf --- /dev/null +++ b/cmd/db/migrate.go @@ -0,0 +1,97 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/rs/zerolog/log" + migrate "github.com/rubenv/sql-migrate" + "github.com/spf13/cobra" +) + +func newMigrate() *cobra.Command { + return &cobra.Command{ + Use: "migrate", + Short: "Executes all migrations which are not yet applied.", + Run: func(_ *cobra.Command, _ []string) { + migrateCmdFunc() + }, + } +} + +func migrateCmdFunc() { + err := command.WithServer(context.Background(), config.DefaultServiceConfigFromEnv(), func(ctx context.Context, s *api.Server) error { + log := util.LogFromContext(ctx) + + n, err := ApplyMigrations(ctx, s.Config) + if err != nil { + log.Err(err).Msg("Error while applying migrations") + return err + } + + log.Info().Int("appliedMigrationsCount", n).Msg("Successfully applied migrations") + + return nil + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to apply migrations") + } +} + +func ApplyMigrations(ctx context.Context, serviceConfig config.Server) (int, error) { + log := util.LogFromContext(ctx) + + // pin migrate to use the globally defined `migrations` table identifier + migrate.SetTable(config.DatabaseMigrationTable) + + db, err := sql.Open("postgres", serviceConfig.Database.ConnectionString()) + if err != nil { + return 0, fmt.Errorf("failed to open the database: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return 0, fmt.Errorf("failed to ping the database: %w", err) + } + + // In case an old default sql-migrate migration table (named "gorp_migrations") still exists we rename it to the new name equivalent + // in sync with the settings in dbconfig.yml and config.DatabaseMigrationTable. + if _, err := db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS gorp_migrations RENAME TO %s;", config.DatabaseMigrationTable)); err != nil { + return 0, fmt.Errorf("failed to rename migrations table: %w", err) + } + + migrations := &migrate.FileMigrationSource{ + Dir: config.DatabaseMigrationFolder, + } + + missingMigrations, _, err := migrate.PlanMigration(db, "postgres", migrations, migrate.Up, 0) + if err != nil { + log.Err(err).Msg("Error while planning migrations") + return 0, fmt.Errorf("failed to plan migrations: %w", err) + } + + var appliedMigrationsCount int + for i := 0; i < len(missingMigrations); i++ { + log.Info().Str("migrationId", missingMigrations[i].Id).Msg("Applying migration") + + n, err := migrate.ExecMax(db, "postgres", migrations, migrate.Up, 1) + if err != nil { + log.Err(err).Msg("Error while applying migration") + return 0, fmt.Errorf("failed to apply migration: %w", err) + } + + log.Info().Int("appliedMigrationsCount", n).Msg("Applied migration") + + appliedMigrationsCount += n + if n == 0 { + break + } + } + + return appliedMigrationsCount, nil +} diff --git a/cmd/db/seed.go b/cmd/db/seed.go new file mode 100644 index 0000000..a4577cd --- /dev/null +++ b/cmd/db/seed.go @@ -0,0 +1,77 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + data "allaboutapps.dev/aw/go-starter/internal/data/fixtures" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/command" + dbutil "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func newSeed() *cobra.Command { + return &cobra.Command{ + Use: "seed", + Short: "Inserts or updates fixtures to the database.", + Long: `Uses upsert to add test data to the current environment.`, + Run: func(_ *cobra.Command, _ []string) { + seedCmdFunc() + }, + } +} + +func seedCmdFunc() { + err := command.WithServer(context.Background(), config.DefaultServiceConfigFromEnv(), func(ctx context.Context, s *api.Server) error { + log := util.LogFromContext(ctx) + + err := ApplySeedFixtures(ctx, s.Config) + if err != nil { + log.Err(err).Msg("Error while applying seed fixtures") + return err + } + + log.Info().Msg("Successfully applied seed fixtures") + + return nil + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to apply migrations") + } +} + +func ApplySeedFixtures(ctx context.Context, config config.Server) error { + log := util.LogFromContext(ctx) + + db, err := sql.Open("postgres", config.Database.ConnectionString()) + if err != nil { + return fmt.Errorf("failed to open the database: %w", err) + } + + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("failed to ping the database: %w", err) + } + + // insert fixtures in an auto-managed db transaction + return dbutil.WithTransaction(ctx, db, func(tx boil.ContextExecutor) error { + fixtures := data.Upserts() + + for _, fixture := range fixtures { + if err := fixture.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + log.Error().Err(err).Msg("Failed to upsert fixture") + return err + } + } + + log.Info().Int("fixturesCount", len(fixtures)).Msg("Successfully upserted fixtures") + return nil + }) +} diff --git a/cmd/env/env.go b/cmd/env/env.go new file mode 100644 index 0000000..6bc5e9e --- /dev/null +++ b/cmd/env/env.go @@ -0,0 +1,38 @@ +package env + +import ( + "encoding/json" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/config" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + return &cobra.Command{ + Use: "env", + Short: "Prints the env", + Long: `Prints the currently applied env + + You may use this cmd to get an overview about how + your ENV_VARS are bound by the server config. + Please note that certain secrets are automatically + removed from this output.`, + Run: func(_ *cobra.Command /* cmd */, _ []string /* args */) { + runEnv() + }, + } +} + +func runEnv() { + config := config.DefaultServiceConfigFromEnv() + + result, err := json.MarshalIndent(config, "", " ") + if err != nil { + log.Fatal().Err(err).Msg("Failed to marshal the env") + } + + //nolint:forbidigo + fmt.Println(string(result)) +} diff --git a/cmd/probe/liveness.go b/cmd/probe/liveness.go new file mode 100644 index 0000000..34f3e04 --- /dev/null +++ b/cmd/probe/liveness.go @@ -0,0 +1,89 @@ +package probe + +import ( + "context" + "database/sql" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/common" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type LivenessFlags struct { + Verbose bool +} + +func newLiveness() *cobra.Command { + var flags LivenessFlags + + cmd := &cobra.Command{ + Use: "liveness", + Short: "Runs liveness probes", + Long: `Runs connection livenesss probes + + This command triggers the same livenesss probes as in + /-/healthy (apart from the actual server.ready + probe) and prints the results to stdout. Fails with + non zero exitcode on encountered errors. + + A typical usecase of this command are liveness probes + to take action if dependant services (e.g. DB, NFS + mounts) become unstable. You may also use this to + ensure all requirements are fulfilled before starting + the app server.`, + Run: func(_ *cobra.Command, _ []string) { + livenessCmdFunc(flags) + }, + } + + cmd.Flags().BoolVarP(&flags.Verbose, verboseFlag, "v", false, "Show verbose output.") + + return cmd +} + +func livenessCmdFunc(flags LivenessFlags) { + err := command.WithServer(context.Background(), config.DefaultServiceConfigFromEnv(), func(ctx context.Context, s *api.Server) error { + log := util.LogFromContext(ctx) + + errs, err := runLiveness(ctx, s.Config, flags) + if err != nil { + log.Fatal().Err(err).Msg("Failed to run liveness probes") + } + + if len(errs) > 0 { + log.Fatal().Errs("errs", errs).Msg("Unhealthy.") + } + + return nil + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to run liveness probes") + } +} + +func runLiveness(ctx context.Context, config config.Server, flags LivenessFlags) ([]error, error) { + log := util.LogFromContext(ctx) + + db, err := sql.Open("postgres", config.Database.ConnectionString()) + if err != nil { + log.Error().Err(err).Msg("Failed to open the database") + return nil, fmt.Errorf("failed to open the database: %w", err) + } + defer db.Close() + + livenessCtx, cancel := context.WithTimeout(context.Background(), config.Management.LivenessTimeout) + defer cancel() + + str, errs := common.ProbeLiveness(livenessCtx, db, config.Management.ProbeWriteablePathsAbs, config.Management.ProbeWriteableTouchfile) + + if flags.Verbose { + log.Info().Msg(str) + } + + return errs, nil +} diff --git a/cmd/probe/probe.go b/cmd/probe/probe.go new file mode 100644 index 0000000..bc0a3cd --- /dev/null +++ b/cmd/probe/probe.go @@ -0,0 +1,17 @@ +package probe + +import ( + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/spf13/cobra" +) + +const ( + verboseFlag string = "verbose" +) + +func New() *cobra.Command { + return command.NewSubcommandGroup("probe", + newLiveness(), + newReadiness(), + ) +} diff --git a/cmd/probe/readiness.go b/cmd/probe/readiness.go new file mode 100644 index 0000000..91174c2 --- /dev/null +++ b/cmd/probe/readiness.go @@ -0,0 +1,89 @@ +package probe + +import ( + "context" + "database/sql" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/common" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type ReadinessFlags struct { + Verbose bool +} + +func newReadiness() *cobra.Command { + var flags ReadinessFlags + + cmd := &cobra.Command{ + Use: "readiness", + Short: "Runs readiness probes", + Long: `Runs connection readinesss probes + + This command triggers the same readinesss probes as in + /-/ready (apart from the actual server.ready + probe) and prints the results to stdout. Fails with + non zero exitcode on encountered errors. + + A typical usecase of this command are readiness probes + to take action if dependant services (e.g. DB, NFS + mounts) become unstable. You may also use this to + ensure all requirements are fulfilled before starting + the app server.`, + Run: func(_ *cobra.Command, _ []string /* args */) { + readinessCmdFunc(flags) + }, + } + + cmd.Flags().BoolVarP(&flags.Verbose, verboseFlag, "v", false, "Show verbose output.") + + return cmd +} + +func readinessCmdFunc(flags ReadinessFlags) { + err := command.WithServer(context.Background(), config.DefaultServiceConfigFromEnv(), func(ctx context.Context, s *api.Server) error { + log := util.LogFromContext(ctx) + + errs, err := RunReadiness(ctx, s.Config, flags) + if err != nil { + log.Fatal().Err(err).Msg("Failed to run readiness probes") + } + + if len(errs) > 0 { + log.Fatal().Errs("errs", errs).Msg("Unhealthy.") + } + + return nil + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to run readiness probes") + } +} + +func RunReadiness(ctx context.Context, config config.Server, flags ReadinessFlags) ([]error, error) { + log := util.LogFromContext(ctx) + + db, err := sql.Open("postgres", config.Database.ConnectionString()) + if err != nil { + log.Error().Err(err).Msg("Failed to open the database") + return nil, fmt.Errorf("failed to open the database: %w", err) + } + defer db.Close() + + readinessCtx, cancel := context.WithTimeout(context.Background(), config.Management.ReadinessTimeout) + defer cancel() + + str, errs := common.ProbeReadiness(readinessCtx, db, config.Management.ProbeWriteablePathsAbs) + + if flags.Verbose { + log.Info().Msg(str) + } + + return errs, nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..4358ebd --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "os" + + "allaboutapps.dev/aw/go-starter/cmd/db" + "allaboutapps.dev/aw/go-starter/cmd/env" + "allaboutapps.dev/aw/go-starter/cmd/probe" + "allaboutapps.dev/aw/go-starter/cmd/server" + "allaboutapps.dev/aw/go-starter/internal/config" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Version: config.GetFormattedBuildArgs(), + Use: "app", + Short: config.ModuleName, + Long: fmt.Sprintf(`%v + +A stateless RESTful JSON service written in Go. +Requires configuration through ENV.`, config.ModuleName), +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + rootCmd.SetVersionTemplate(`{{printf "%s\n" .Version}}`) + + // attach the subcommands + rootCmd.AddCommand( + db.New(), + env.New(), + probe.New(), + server.New(), + ) + + if err := rootCmd.Execute(); err != nil { + log.Error().Err(err).Msg("Failed to execute root command") + os.Exit(1) + } +} diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..438e294 --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,105 @@ +package server + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + + "allaboutapps.dev/aw/go-starter/cmd/db" + "allaboutapps.dev/aw/go-starter/cmd/probe" + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/router" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type Flags struct { + ProbeReadiness bool + ApplyMigrations bool + SeedFixtures bool +} + +func New() *cobra.Command { + var flags Flags + + cmd := &cobra.Command{ + Use: "server", + Short: "Starts the server", + Long: `Starts the stateless RESTful JSON server + + Requires configuration through ENV and + a fully migrated PostgreSQL database.`, + Run: func(_ *cobra.Command, _ []string) { + runServer(flags) + }, + } + + cmd.Flags().BoolVarP(&flags.ProbeReadiness, "probe", "p", false, "Probe readiness before startup.") + cmd.Flags().BoolVarP(&flags.ApplyMigrations, "migrate", "m", false, "Apply migrations before startup.") + cmd.Flags().BoolVarP(&flags.SeedFixtures, "seed", "s", false, "Seed fixtures into database before startup.") + + return cmd +} + +func runServer(flags Flags) { + err := command.WithServer(context.Background(), config.DefaultServiceConfigFromEnv(), func(ctx context.Context, s *api.Server) error { + log := util.LogFromContext(ctx) + + if flags.ProbeReadiness { + errs, err := probe.RunReadiness(ctx, s.Config, probe.ReadinessFlags{ + Verbose: true, + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to run readiness probes") + } + + if len(errs) > 0 { + log.Fatal().Errs("errs", errs).Msg("Unhealthy.") + } + } + + if flags.ApplyMigrations { + _, err := db.ApplyMigrations(ctx, s.Config) + if err != nil { + log.Fatal().Err(err).Msg("Error while applying migrations") + } + } + + if flags.SeedFixtures { + err := db.ApplySeedFixtures(ctx, s.Config) + if err != nil { + log.Fatal().Err(err).Msg("Error while applying seed fixtures") + } + } + + err := router.Init(s) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize router") + } + + go func() { + if err := s.Start(); err != nil { + if errors.Is(err, http.ErrServerClosed) { + log.Info().Msg("Server closed") + } else { + log.Fatal().Err(err).Msg("Failed to start server") + } + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + + return nil + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to start server") + } +} diff --git a/dbconfig.yml b/dbconfig.yml new file mode 100644 index 0000000..35d5ae4 --- /dev/null +++ b/dbconfig.yml @@ -0,0 +1,15 @@ +# Uses PG* env vars +# the live database +development: + dialect: postgres + datasource: host=${PGHOST} dbname=${PGDATABASE} user=${PGUSER} password=${PGPASSWORD} port=${PGPORT} sslmode=${PGSSLMODE} + dir: migrations + table: migrations + +# Uses PSQL_* env vars +# the spec database (used for introspection purposes e.g. generate the sqlboiler models) +spec: + dialect: postgres + datasource: host=${PSQL_HOST} dbname=${PSQL_DBNAME} user=${PSQL_USER} password=${PSQL_PASS} port=${PSQL_PORT} sslmode=${PSQL_SSLMODE} + dir: migrations + table: migrations \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3115e59 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,168 @@ +services: + service: + build: + context: . + target: development + ports: + - "8080:8080" + working_dir: &PROJECT_ROOT_DIR /app + # linux permissions / vscode support: we must explicitly run as the development user + user: development + volumes: + # mount working directory + # https://code.visualstudio.com/docs/remote/containers-advanced#_update-the-mount-consistency-to-delegated-for-macos + # https://docs.docker.com/docker-for-mac/osxfs-caching/#delegated + # the container’s view is authoritative (permit delays before updates on the container appear in the host) + - .:/app:delegated + + # mount cached go pkg downloads + - go-pkg:/go/pkg + + # speed up tmp dirs in working directory by using separate volumes (not the host's filesystem) + - workdir-api-tmp:/app/api/tmp + - workdir-bin:/app/bin + - workdir-tmp:/app/tmp + + # mount cached vscode container extensions + # https://code.visualstudio.com/docs/remote/containers-advanced#_avoiding-extension-reinstalls-on-container-rebuild + - vscode-extensions:/home/development/.vscode-server/extensions + - vscode-extensions-insiders:/home/development/.vscode-server-insiders/extensions + + # https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history + # keep user development .bash_history between container restarts + - bash-history:/home/development/commandhistory + + depends_on: + - postgres + - integresql + environment: + # required: env for main working database, service + # default for sql-migrate (target development) and psql cli tool + PGDATABASE: &PGDATABASE "development" + PGUSER: &PGUSER "dbuser" + PGPASSWORD: &PGPASSWORD "dbpass" + PGHOST: &PGHOST "postgres" + PGPORT: &PGPORT "5432" + PGSSLMODE: &PGSSLMODE "disable" + + # optional: env for sql-boiler (ability to generate models out of a "spec" database) + # sql-boiler should operate on a "spec" database only + PSQL_DBNAME: "spec" + PSQL_USER: *PGUSER + PSQL_PASS: *PGPASSWORD + PSQL_HOST: *PGHOST + PSQL_PORT: *PGPORT + PSQL_SSLMODE: *PGSSLMODE + + # optional: project root directory, used for relative path resolution (e.g. fixtures) + PROJECT_ROOT_DIR: *PROJECT_ROOT_DIR + + # optional: env for integresql client testing + # INTEGRESQL_CLIENT_BASE_URL: "http://integresql:5000/api" + + # optional: enable pretty print of log output + # intended use is for development and debugging purposes only + # not recommended to enable on production systems due to performance penalty and loss of parsing ability + SERVER_LOGGER_PRETTY_PRINT_CONSOLE: "true" + + # optional: static management secret to easily call http://localhost:8080/-/healthy?mgmt-secret=mgmtpass + SERVER_MANAGEMENT_SECRET: "mgmtpass" + + # path to the changie config + CHANGIE_CONFIG_PATH: "/app/.changie-go-starter.yaml" + + # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. + cap_add: + - SYS_PTRACE + security_opt: + - seccomp:unconfined + + # Overrides default command so things don't shut down after the process ends. + command: + - /bin/sh + - -c + - | + sudo chown -R development:development /app/api/tmp + sudo chown -R development:development /app/bin + sudo chown -R development:development /app/tmp + chmod +x /app/rksh + git config --global --add safe.directory /app + while sleep 1000; do :; done + + postgres: + image: postgres:17.4-alpine # should be the same version as used in .drone.yml, .github/workflows, Dockerfile and live + # ATTENTION + # fsync=off, synchronous_commit=off and full_page_writes=off + # gives us a major speed up during local development and testing (~30%), + # however you should NEVER use these settings in PRODUCTION unless + # you want to have CORRUPTED data. + # DO NOT COPY/PASTE THIS BLINDLY. + # YOU HAVE BEEN WARNED. + # Apply some performance improvements to pg as these guarantees are not needed while running locally + command: "postgres -c 'shared_buffers=128MB' -c 'fsync=off' -c 'synchronous_commit=off' -c 'full_page_writes=off' -c 'max_connections=100' -c 'client_min_messages=warning'" + expose: + - "5432" + ports: + - "5432:5432" + environment: + POSTGRES_DB: *PGDATABASE + POSTGRES_USER: *PGUSER + POSTGRES_PASSWORD: *PGPASSWORD + volumes: + - pgvolume:/var/lib/postgresql/data + + integresql: + image: ghcr.io/allaboutapps/integresql:v1.1.0 + expose: + - "5000" + depends_on: + - postgres + environment: + PGHOST: *PGHOST + PGUSER: *PGUSER + PGPASSWORD: *PGPASSWORD + + mailhog: + image: mailhog/mailhog + expose: + - "1025" + ports: + - "8025:8025" + + swaggerui: + image: swaggerapi/swagger-ui:v3.46.0 + environment: + SWAGGER_JSON: "/api/swagger.yml" + volumes: + # mount our local main swagger.yml file (refresh your browser to see changes) + - ./api:/api:ro,consistent + # mount overwritten translator.js (intercept requests port 8081 to our local service on port 8080) + - ./api/config/swagger-ui-local-translator.js:/usr/share/nginx/configurator/translator.js:ro,delegated + + swaggerui-browser-sync: + image: allaboutapps/browser-sync:v2.26.14 + command: start --proxy 'swaggerui:8080' --port 8081 --files "/api/*.yml" + volumes: + - ./api:/api:ro,consistent + ports: + - "8081:8081" + +volumes: + # postgresql: declare a named volume to persist DB data + pgvolume: + + # go: go mod cached downloads + go-pkg: + + # tmp dirs in workdir + workdir-api-tmp: + workdir-bin: + workdir-tmp: + + # vscode: Avoiding extension reinstalls on container rebuild + # https://code.visualstudio.com/docs/remote/containers-advanced#_avoiding-extension-reinstalls-on-container-rebuild + vscode-extensions: + vscode-extensions-insiders: + + # https://code.visualstudio.com/remote/advancedcontainers/persist-bash-history + bash-history: diff --git a/docker-helper.sh b/docker-helper.sh new file mode 100755 index 0000000..0ef9251 --- /dev/null +++ b/docker-helper.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +if [ "$1" = "--up" ]; then + docker compose up --no-start + docker compose start # ensure we are started, handle also allowed to be consumed by vscode + docker compose exec service bash +fi + +if [ "$1" = "--halt" ]; then + docker compose stop +fi + +if [ "$1" = "--rebuild" ]; then + docker compose up -d --force-recreate --no-deps --build service +fi + +if [ "$1" = "--destroy" ]; then + docker compose down --rmi local -v --remove-orphans +fi + +[ -n "$1" -a \( "$1" = "--up" -o "$1" = "--halt" -o "$1" = "--rebuild" -o "$1" = "--destroy" \) ] \ + || { echo "usage: $0 --up | --halt | --rebuild | --destroy" >&2; exit 1; } \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..75d532a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# `/docs` + +Design and user documents (in addition to your godoc generated documentation). + +https://github.com/golang-standards/project-layout/tree/master/docs + +Examples: + +* https://github.com/gohugoio/hugo/tree/master/docs +* https://github.com/openshift/origin/tree/master/docs +* https://github.com/dapr/dapr/tree/master/docs \ No newline at end of file diff --git a/docs/schemacrawler/.gitignore b/docs/schemacrawler/.gitignore new file mode 100644 index 0000000..dc2dff2 --- /dev/null +++ b/docs/schemacrawler/.gitignore @@ -0,0 +1 @@ +schema.* \ No newline at end of file diff --git a/docs/schemacrawler/README.md b/docs/schemacrawler/README.md new file mode 100644 index 0000000..861ff97 --- /dev/null +++ b/docs/schemacrawler/README.md @@ -0,0 +1,31 @@ +# `/docs/schemacrawler` + +To locally (re-)generate a schemacrawler diagramm, execute any of the following commands from your **host** machine. + +```bash +# Note that the project must be already running within docker-compose (and the "spec" database should already be migrated via "make sql" or "make all"). +# First find out under which docker network the "allaboutapps.dev/aw/go-starter" project is available (as started via ./docker-helper.sh --up). +# Typically it's "_default". +docker network ls +# [...] +# go-starter_default + +# Ensure you are within the /docs/schemacrawler directory +cd docs/schemacrawler +pwd +# [...]/docs/schemacrawler + +# Generate a png (exchange --network="..." with your docker network before executing this command) +docker run --network=go-starter_default -v $(pwd):/home/schcrwlr/share -v $(pwd)/schemacrawler.config.properties:/opt/schemacrawler/config/schemacrawler.config.properties --entrypoint=/opt/schemacrawler/bin/schemacrawler.sh schemacrawler/schemacrawler --server=postgresql --host=postgres --port=5432 --database=spec --schemas=public --user=dbuser --password=dbpass --info-level=standard --command=schema --portable-names --title "allaboutapps.dev/aw/go-starter" --output-format=png --output-file=/home/schcrwlr/share/schema.png + +# Generate a pdf (exchange --network="..." with your docker network before executing this command) +docker run --network=go-starter_default -v $(pwd):/home/schcrwlr/share -v $(pwd)/schemacrawler.config.properties:/opt/schemacrawler/config/schemacrawler.config.properties --entrypoint=/opt/schemacrawler/bin/schemacrawler.sh schemacrawler/schemacrawler --server=postgresql --host=postgres --port=5432 --database=spec --schemas=public --user=dbuser --password=dbpass --info-level=standard --command=schema --portable-names --title "allaboutapps.dev/aw/go-starter" --output-format=pdf --output-file=/home/schcrwlr/share/schema.pdf + +# Feel free to override schemacrawler configuration settings in "./schemacrawler.config.properties". +``` + +For further information see: +- Latest [Dockerfile](https://github.com/schemacrawler/SchemaCrawler/blob/master/schemacrawler-docker/Dockerfile) of schemacrawler +- [SchemaCrawler Database Diagramming](https://www.schemacrawler.com/diagramming.html) (intro to most diagramming options) +- [Docker Image for SchemaCrawler](https://www.schemacrawler.com/docker-image.html) (about running schemacrawler in Docker) +- [DockerHub `schemacrawler/schemacrawler`](https://hub.docker.com/r/schemacrawler/schemacrawler/) (available version of this Docker image) \ No newline at end of file diff --git a/docs/schemacrawler/schemacrawler.config.properties b/docs/schemacrawler/schemacrawler.config.properties new file mode 100644 index 0000000..c49033c --- /dev/null +++ b/docs/schemacrawler/schemacrawler.config.properties @@ -0,0 +1,181 @@ +# --=----=----=----=----=----=----=----=----=----=----=----=----=----=----=----= +# - SchemaCrawler: Configuration Options +# --=----=----=----=----=----=----=----=----=----=----=----=----=----=----=----= +# +# - Metadata Retrieval Options +# ------------------------------------------------------------------------------ +# - Override the metadata retrieval strategy +# - This can affect speed, so they are commented out in order to use database +# - specific defaults +# - Default: Hard-coded into each database plugin, otherwise metadata +# - Possible values for each property are metadata or data_dictionary_all +#schemacrawler.schema.retrieval.strategy.typeinfo=metadata +#schemacrawler.schema.retrieval.strategy.tables=metadata +#schemacrawler.schema.retrieval.strategy.tablecolumns=metadata +#schemacrawler.schema.retrieval.strategy.primarykeys=metadata +#schemacrawler.schema.retrieval.strategy.indexes=metadata +#schemacrawler.schema.retrieval.strategy.foreignkeys=metadata +#schemacrawler.schema.retrieval.strategy.procedures=metadata +#schemacrawler.schema.retrieval.strategy.procedurecolumns=metadata +#schemacrawler.schema.retrieval.strategy.functions=metadata +#schemacrawler.schema.retrieval.strategy.functioncolumns=metadata +# +# - Limit Options - inclusion rules for database objects +# ------------------------------------------------------------------------------ +# - Regular expression schema pattern to filter +# - schema names +# - Default: .* for include, for exclude +# - IMPORTANT: Please uncomment the follow patterns only for +# - database that support schemas. SQLite for example does +# - not support schemas +#schemacrawler.schema.pattern.include=.* +#schemacrawler.schema.pattern.exclude= +# - Regular expression table and column name pattern to filter table +# - and column names +# - Column regular expression to match fully qualified column names, +# - in the form "CATALOGNAME.SCHEMANAME.TABLENAME.COLUMNNAME" +# - Default: .* for include, for exclude +#schemacrawler.table.pattern.include=.* +#schemacrawler.table.pattern.exclude= +#schemacrawler.column.pattern.include=.* +#schemacrawler.column.pattern.exclude= +# - Regular expression routine and routine parameter name pattern to filter +# - routine and routine parameter names +# - Default: .* for include, for exclude +#schemacrawler.routine.pattern.include= +#schemacrawler.routine.pattern.exclude=.* +#schemacrawler.routine.inout.pattern.include=.* +#schemacrawler.routine.inout.pattern.exclude= +# - Regular expression synonym pattern to filter +# - synonym names +# - Default: for include, .* for exclude +#schemacrawler.synonym.pattern.include= +#schemacrawler.synonym.pattern.exclude=.* +# - Regular expression sequence pattern to filter +# - sequence names +# - Default: for include, .* for exclude +#schemacrawler.sequence.pattern.include= +#schemacrawler.sequence.pattern.exclude=.* +# +# - Grep Options - inclusion rules +# ------------------------------------------------------------------------------ +# - Include patterns for table columns +# - Default: .* for include, for exclude +#schemacrawler.grep.column.pattern.include=.* +#schemacrawler.grep.column.pattern.exclude= +# - Include patterns for routine parameters +# - Default: .* for include, for exclude +#schemacrawler.grep.routine.inout.pattern.include=.* +#schemacrawler.grep.routine.inout.pattern.exclude= +# - Include patterns for table and routine definitions +# - Default: .* for include, for exclude +#schemacrawler.grep.definition.pattern.include=.* +#schemacrawler.grep.definition.pattern.exclude= +# +# - Sorting Options +# ------------------------------------------------------------------------------ +# - Sort orders for objects +#schemacrawler.format.sort_alphabetically.tables=true +#schemacrawler.format.sort_alphabetically.table_columns=false +#schemacrawler.format.sort_alphabetically.table_foreignkeys=false +#schemacrawler.format.sort_alphabetically.table_indexes=false +#schemacrawler.format.sort_alphabetically.routines=true +#schemacrawler.format.sort_alphabetically.routine_columns=false +# +# - Show Options - text output formatting +# ------------------------------------------------------------------------------ +# - Controls generation of the SchemaCrawler header and footer in output +# - Default: false +#schemacrawler.format.no_header=false +#schemacrawler.format.no_footer=false +schemacrawler.format.no_schemacrawler_info=true +schemacrawler.format.show_database_info=true +#schemacrawler.format.show_jdbc_driver_info=false +# - Controls display of remarks for tables and columns in output +# - Default: false +#schemacrawler.format.hide_remarks=false +# - Shows all object names with the catalog and schema names, for easier comparison +# - across different schemas +# - Default: false +#schemacrawler.format.show_unqualified_names=false +# - Shows standard column names instead of database specific column names +# - Default: false +#schemacrawler.format.show_standard_column_type_names=false +# - Shows ordinal numbers for columns +# - Default: false +#schemacrawler.format.show_ordinal_numbers=false +# - Shows table row counts - use with --info-level=maximum +# - Default: false +#schemacrawler.format.show_row_counts=false +# - If foreign key names, constraint names, trigger names, +# - specific names for routines, or index and primary key names +# - are not explicitly provided while creating a schema, most +# - database systems assign default names. These names can show +# - up as spurious diffs in SchemaCrawler output. +# - All of these are hidden with the --portable-names +# - command-line option. For more control, use the following +# - options. +# - Hides foreign key names, constraint names, trigger names, +# - specific names for routines, index and primary key names +# - Default: false +#schemacrawler.format.hide_primarykey_names=false +#schemacrawler.format.hide_foreignkey_names=false +#schemacrawler.format.hide_index_names=false +#schemacrawler.format.hide_trigger_names=false +#schemacrawler.format.hide_routine_specific_names=false +#schemacrawler.format.hide_constraint_names=false +#schemacrawler.format.show_weak_associations=false +# Specifies how to quote (delimit) database object names in text output +# Options are +# - quote_none - Do not quote any database object names +# - quote_all - Always quote database object names +# - quote_if_special_characters - Only quote database object names +# if they contain special characters +# - quote_if_special_characters_and_reserved_words - Quote database object names +# if they contain special characters or SQL 2003 reserved words +# - Default: quote_if_special_characters_and_reserved_words +#schemacrawler.format.identifier_quoting_strategy=quote_if_special_characters_and_reserved_words +# - Does not color-code catalog and schema names. +# - Default: false +#schemacrawler.format.no_schema_colors=false +# - Encoding of input files, such as Apache Velocity templates +# - Default: UTF-8 +#schemacrawler.encoding.input=UTF-8 +# - Encoding of SchemaCrawler output files +# - Default: UTF-8 +#schemacrawler.encoding.output=UTF-8 +# +# - Graphing Options +# - (some graphing options may be controlled by text formatting options) +# ------------------------------------------------------------------------------ +# - Show a crow's foot symbol to indicate cardinality +# - Default: true +#schemacrawler.graph.show.primarykey.cardinality=true +#schemacrawler.graph.show.foreignkey.cardinality=true +# +# - Graph attributes for Graphviz, supporting graph, node and edge +# - See https://www.graphviz.org/doc/info/attrs.html +schemacrawler.graph.graphviz.graph.rankdir=RL +schemacrawler.graph.graphviz.graph.labeljust=r +schemacrawler.graph.graphviz.graph.fontname=Helvetica +schemacrawler.graph.graphviz.node.fontname=Helvetica +schemacrawler.graph.graphviz.node.shape=none +schemacrawler.graph.graphviz.edge.fontname=Helvetica + +# schemacrawler.graph.graphviz.graph.splines=ortho + +# - Additional options for Graphviz, to control diagram generation +# - See https://www.graphviz.org/doc/info/command.html +# schemacrawler.graph.graphviz_opts=-Gdpi=150 +# - Data Output Options +# ------------------------------------------------------------------------------ +# - Whether to show data from CLOB and BLOB objects +# - Default: false +#schemacrawler.data.show_lobs=false +# --=----=----=----=----=----=----=----=----=----=----=----=----=----=----=----= +# Queries +# --=----=----=----=----=----=----=----=----=----=----=----=----=----=----=----= +# Define your own named queries, which then become SchemaCrawler command +hsqldb.tables=SELECT * FROM INFORMATION_SCHEMA.SYSTEM_TABLES +tables.select=SELECT ${columns} FROM ${table} ORDER BY ${columns} +tables.drop=DROP ${tabletype} ${table} diff --git a/docs/server-initialization.md b/docs/server-initialization.md new file mode 100644 index 0000000..08f265c --- /dev/null +++ b/docs/server-initialization.md @@ -0,0 +1,106 @@ +# Server Initialization + +As our projects have grown, we have gradually added more and more dependent components. Consequently, fully initializing the top-level `Server` struct has become a rather complex task, especially considering the lack of reliable protection against accidental usage of an uninitialized `Server` instance or attached services. + +To simplify our workflow, we made the decision to integrate the **wire** code generation tool into our project. This tool effectively resolves the dependency graph and ensures that the server components are always initialized in the correct order. + +> Wire is a code generation tool that automates connecting components using dependency injection. + +https://pkg.go.dev/github.com/google/wire. + +To accomplish this, we had to introduce certain changes, which may be potentially breaking. We acknowledge that the downstream projects will require some effort in adapting to these changes. Nevertheless, we strongly believe that the long-term benefits will justify the initial investment. + +Please read the following instructions carefully. + +## 1. Define providers +**BEFORE**: server components initialized within the methods defined on the `Server` struct. +For example: +```go +func (s *Server) InitPush() error { +``` + +**AFTER**: providers defined according to the wire guideline: https://github.com/google/wire/blob/main/docs/guide.md#defining-providers. + +Providers are just ordinary functions getting the necessary dependencies as input parameters and returning an initialized component. +For example: +```go +func NewPush(cfg config.Server, db *sql.DB) (*push.Service, error) { +``` + +### REQUIRED ACTION +Convert all `func (s *Server) Init*` methods into providers conforming to the wire guidelines. + +If for any reason a provider function can't live in it's dedicated package, you can place it in `providers.go`. + +Please refer to `internal/api/wire.go` to check currently used wire providers. They are listed as params to the `wire.Build()` function. + +## 2. Define injectors +Injectors are declared in `internal/api/wire.go`. They instruct wire which providers should be used to satisfy the dependencies of a top level component. + +### REQUIRED ACTION +Add the providers defined in the previous step to the `InitNewServer*` functions in `internal/api/wire.go`. More injectors might be added if needed. + +### Some hints: + - Components that should be skipped by wire should be labeled with `wire:"-"` (although this is not recommended). + - The order of `wire.Build()` arguments doesn't matter. + - If any provider is missing, wire generation will fail. Also, if any provider failes to create a dependent component in runtime, `InitNewServer*` returns an error. + - If there are two or more components of the same type, declare a custom type for each of them to let wire identify the right provider to be used: https://github.com/google/wire/blob/main/docs/best-practices.md#distinguishing-types. A newly created type should be returned by a corresponding provider. + - If a `Server`'s member is an interface and the corresponding provider returns a pointer to a struct, use the `Bind` function: https://pkg.go.dev/github.com/google/wire#Bind. + - Providers commonly used together might be grouped into sets: https://pkg.go.dev/github.com/google/wire#ProviderSet. + +## 3. Generate +Functions declared in wire.go are just recepies for code generation and are excluded from build with the `+build wireinject` directive. +To generate the code, wire command has to be invoked in the directory where wire.go resides. + +Generated code can appears in `wire_gen.go` and looks like: +```go +func InitNewServer(cfg config.Server) (*Server, error) { + db, err := NewDB(cfg) + if err != nil { + return nil, err + } + service, err := NewPush(cfg, db) +``` + +Because `wire.go` is excluded from build, function signatures can be duplicated in `wire_gen.go`. On any `wire.go` change, `wire_gen.go` needs to be updated. + +Wire generation has been added to Makefile step `go-generate`. + +### REQUIRED ACTION +#### Wire tool installation: +```sh +make init +``` + +#### Wire generation: +```sh +wire gen ./... +``` +or via `make`: +```sh +make go-generate +``` + +On errors you can simply remove `wire_gen.go` and try again - sometimes it helps. +Otherwire, see [this section](#some-hints) or get familiar with the wire documentation. + +## 4. Integrate into existing code + +After successful generation, `func (s *Server) Init*` methods are no longer needed. We can remove them and update each place where they have been used previously to use newly generated functions. + +### REQUIRED ACTION +Remove all `func (s *Server) Init*` methods and run +```sh +make go-build +``` +At each place where the compilation fails do the following: + - remove the usage of these methods. + - replace `NewServer(cfg)` or any other previously used `Server` provider with the corresponding function generated by wire, for example `InitNewServer`. + +Sometimes having a fully initialized server is not required (see `scripts/internal/handlers/check.go`). In such cases, components we want to use need a manual initialization - the providers have to be invoked in the right order and the resulting components have to be assigned to the `Server`. + +When you're done, verify the sweet fruits of your labor with: +```sh +make all +make test +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..48a15a0 --- /dev/null +++ b/go.mod @@ -0,0 +1,150 @@ +module allaboutapps.dev/aw/go-starter + +go 1.24.0 + +require ( + github.com/BurntSushi/toml v1.5.0 + github.com/aarondl/null/v8 v8.1.3 + github.com/aarondl/randomize v0.0.2 + github.com/aarondl/sqlboiler/v4 v4.19.5 + github.com/aarondl/strmangle v0.0.9 + github.com/allaboutapps/integresql-client-go v1.0.0 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc + github.com/friendsofgo/errors v0.9.2 + github.com/gabriel-vasile/mimetype v1.4.9 + github.com/go-openapi/errors v0.22.2 + github.com/go-openapi/runtime v0.28.0 + github.com/go-openapi/strfmt v0.23.0 + github.com/go-openapi/swag v0.23.1 + github.com/go-openapi/validate v0.24.0 + github.com/google/uuid v1.6.0 + github.com/google/wire v0.6.0 + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible + github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12 + github.com/labstack/echo/v4 v4.13.4 + github.com/lib/pq v1.10.9 + github.com/nicksnyder/go-i18n/v2 v2.4.1 + github.com/pkg/errors v0.9.1 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 + github.com/prometheus/client_golang v1.23.0 + github.com/rs/zerolog v1.34.0 + github.com/rubenv/sql-migrate v1.8.0 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.10.0 + github.com/subosito/gotenv v1.6.0 + golang.org/x/crypto v0.41.0 + golang.org/x/mod v0.26.0 + golang.org/x/sys v0.35.0 + golang.org/x/text v0.28.0 + google.golang.org/api v0.247.0 +) + +require ( + github.com/aarondl/inflect v0.0.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/subcommands v1.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + golang.org/x/tools v0.35.0 // indirect +) + +require ( + cloud.google.com/go/auth v0.16.4 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/bgentry/speakeasy v0.2.0 // indirect + github.com/denisenkom/go-mssqldb v0.12.3 // indirect + github.com/dlmiddlecote/sqlstats v1.0.2 + github.com/dropbox/godropbox v0.0.0-20230623171840-436d2007a9fd + github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.21.2 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/godror/godror v0.46.0 // indirect + github.com/godror/knownpb v0.2.0 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/labstack/echo-contrib v0.17.4 + github.com/labstack/gommon v0.4.2 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-oci8 v0.1.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/mitchellh/cli v1.1.5 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/posener/complete v1.2.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.10.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.14.0 // indirect + github.com/spf13/cast v1.9.2 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.mongodb.org/mongo-driver v1.17.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/grpc v1.74.2 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool ( + github.com/aarondl/sqlboiler/v4 + github.com/aarondl/sqlboiler/v4/drivers/sqlboiler-psql + github.com/google/wire/cmd/wire + github.com/rubenv/sql-migrate/sql-migrate +) diff --git a/go.not b/go.not new file mode 100644 index 0000000..78e3042 --- /dev/null +++ b/go.not @@ -0,0 +1,7 @@ +# Specifies go modules that should *never* be embedded in the final app executable (for testing only) +github.com/allaboutapps/integresql-client-go +github.com/davecgh/go-spew +github.com/pmezard/go-difflib +github.com/stretchr/testify +github.com/rogpeppe/go-internal +github.com/kat-co/vala \ No newline at end of file diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..90d5420 --- /dev/null +++ b/go.sum @@ -0,0 +1,489 @@ +cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= +cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1MRDJM= +github.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/UNO-SOFT/zlog v0.8.1 h1:TEFkGJHtUfTRgMkLZiAjLSHALjwSBdw6/zByMC5GJt4= +github.com/UNO-SOFT/zlog v0.8.1/go.mod h1:yqFOjn3OhvJ4j7ArJqQNA+9V+u6t9zSAyIZdWdMweWc= +github.com/aarondl/inflect v0.0.2 h1:XvH8K5g1wKS921tMmDOUsZ3zS1Eo8WwK5RHC0IGGT2s= +github.com/aarondl/inflect v0.0.2/go.mod h1:zjmCfdXHUDQ9jFOV6SeHknpo0Au6rQhV8GchS4Vzv/0= +github.com/aarondl/null/v8 v8.1.3 h1:ZJcvvj34BkXAguqU7xzDqEmzG86cSBgM8HYxcqeK0+8= +github.com/aarondl/null/v8 v8.1.3/go.mod h1:t30s8PEiGWof1orkBNQ6WKpxjoP8UZHJr7D0AHX3G/A= +github.com/aarondl/randomize v0.0.2 h1:JP+3DMqbIMI/ndNFD3GojA8GXi3aRdN39wZL7EIw+HE= +github.com/aarondl/randomize v0.0.2/go.mod h1:/4icd0VTMi5WGrfWGK/YY8UsHghSck8EWSfi2AFVbUM= +github.com/aarondl/sqlboiler/v4 v4.19.5 h1:/UW1qvOA+ytXjhDg85E7fDW6iqIGP9xDdqFbtqZ3xL8= +github.com/aarondl/sqlboiler/v4 v4.19.5/go.mod h1:PqsFMK0K44NPrqcO24fnft2ePqK2avLvbqxWqsTXXHk= +github.com/aarondl/strmangle v0.0.9 h1:VCT+O1FqRSE9DTK3qR0zRHtB384fdRzuyKfx2ux2xms= +github.com/aarondl/strmangle v0.0.9/go.mod h1:ezNIwvvnuVGuKedP5qt2T+wvzPD8yuOoMzamifXNMlk= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/allaboutapps/integresql-client-go v1.0.0 h1:sVsV2Z78BR5E9la+8TJ4fkzP832z+uHtfDOt7Mo3SKI= +github.com/allaboutapps/integresql-client-go v1.0.0/go.mod h1:C5fz9y+Nnjidhj7Mc9h4qKXSmJanIXMa4nFeEOva0oA= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= +github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= +github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= +github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= +github.com/dlmiddlecote/sqlstats v1.0.2/go.mod h1:0CWaIh/Th+z2aI6Q9Jpfg/o21zmGxWhbByHgQSCUQvY= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dropbox/godropbox v0.0.0-20230623171840-436d2007a9fd h1:s2vYw+2c+7GR1ccOaDuDcKsmNB/4RIxyu5liBm1VRbs= +github.com/dropbox/godropbox v0.0.0-20230623171840-436d2007a9fd/go.mod h1:Vr/Q4p40Kce7JAHDITjDhiy/zk07W4tqD5YVi5FD0PA= +github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQKsZ6L/+Gf9WHbt31GG8NMVcpRqUE+1mMIyo= +github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= +github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= +github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= +github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= +github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godror/godror v0.46.0 h1:/43db84UcoxlooASIsasH8TvZ7E1huwJ64yDtZ2504k= +github.com/godror/godror v0.46.0/go.mod h1:44hxVDzvFSwc+yGyRM+riCLNAY5SwZkUfLzVTh5MXCg= +github.com/godror/knownpb v0.2.0 h1:RJLntksFiKUHoUz3wCCJ8+DBjxSLYHYDNl1xRz0/gXI= +github.com/godror/knownpb v0.2.0/go.mod h1:kRahRJBwqTenpVPleymQ4k433Xz2Wuy7dOeFSuEpmkI= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= +github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12 h1:DQVOxR9qdYEybJUr/c7ku34r3PfajaMYXZwgDM7KuSk= +github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12/go.mod h1:u9MdXq/QageOOSGp7qG4XAQsYUMP+V5zEel/Vrl6OOc= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= +github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-oci8 v0.1.1 h1:aEUDxNAyDG0tv8CA3TArnDQNyc4EhnWlsfxRgDHABHM= +github.com/mattn/go-oci8 v0.1.1/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng= +github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nicksnyder/go-i18n/v2 v2.4.1 h1:zwzjtX4uYyiaU02K5Ia3zSkpJZrByARkRB4V3YPrr0g= +github.com/nicksnyder/go-i18n/v2 v2.4.1/go.mod h1:++Pl70FR6Cki7hdzZRnEEqdc2dJt+SAGotyFg/SvZMk= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= +github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= +github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= +github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 h1:QVxbx5l/0pzciWYOynixQMtUhPYC3YKD6EcUlOsgGqw= +github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09/go.mod h1:Uy/Rnv5WKuOO+PuDhuYLEpUiiKIZtss3z519uk67aF0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= +go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= +google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..78ef512 --- /dev/null +++ b/internal/README.md @@ -0,0 +1,55 @@ +# `/internal` + +Private application and library code. This is the code you don't want others importing in their applications or libraries. Note that this layout pattern is enforced by the Go compiler itself. See the Go 1.4 [`release notes`](https://golang.org/doc/go1.4#internalpackages) for more details. Note that you are not limited to the top level `internal` directory. You can have more than one `internal` directory at any level of your project tree. + +You can optionally add a bit of extra structure to your internal packages to separate your shared and non-shared internal code. It's not required (especially for smaller projects), but it's nice to have visual clues showing the intended package use. Your actual application code can go in the `/internal/app` directory (e.g., `/internal/app/myapp`) and the code shared by those apps in the `/internal/pkg` directory (e.g., `/internal/pkg/myprivlib`). + +https://github.com/golang-standards/project-layout/tree/master/internal + +### `/internal/api` + +Holds API implementations (`/internal/api/handlers`) and general server, router and middleware setup. + +### `/internal/config` + +Holds configuration of this project (translation of `ENV` vars into something useable). + +### `/internal/data` + +Anything data related (e.g. mappers, DAOs, DTOs, ...), may be especially relevant if you do 3rd party data integrations. Use this package to write **reuseable** functions for your `/internal/api/handlers/*`. + +May hold live db fixture data (for `app db seed`). + +### `/internal/i18n` + +Our implementation for i18n/l10n, as available via `api.Server.I18n`. Your own localized i18n translation bundles should live within **`/web/i18n`**. + +### `/internal/mailer` + +Email handling sub-service. + +### `/internal/models` + +> **Autogenerated** [SQLBoiler](https://github.com/volatiletech/sqlboiler#getting-started) models. Do *not* put your own files in here. + +These are based on your current database in `../migrations/*.sql` and updated while running `make`. + +### `/internal/push` + +Push notifications sub-service. + +### `/internal/test` + +Test-Setup related code and general testing utility functions. + +Holds test db fixture data (`/internal/test/fixtures.go`). + +### `/internal/types` + +> **Autogenerated** [go-swagger](https://github.com/go-swagger/go-swagger) types and validations. Do *not* put your own files in here. + +These are based on your Swagger OpenAPI specification in `../api/**/*.yml` and updated while running `make`. + +### `/internal/util` + +Utility functions. \ No newline at end of file diff --git a/internal/api/handlers/auth/delete_user_account.go b/internal/api/handlers/auth/delete_user_account.go new file mode 100644 index 0000000..ee92511 --- /dev/null +++ b/internal/api/handlers/auth/delete_user_account.go @@ -0,0 +1,41 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func DeleteUserAccountRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.DELETE("/account", deleteUserAccountHandler(s)) +} + +func deleteUserAccountHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + user := auth.UserFromContext(ctx) + log := util.LogFromContext(ctx) + + var body types.DeleteUserAccountPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + err := s.Auth.DeleteUserAccount(ctx, dto.DeleteUserAccountRequest{ + User: *user, + CurrentPassword: swag.StringValue(body.CurrentPassword), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to delete user") + return err + } + + return c.NoContent(http.StatusNoContent) + } +} diff --git a/internal/api/handlers/auth/delete_user_account_test.go b/internal/api/handlers/auth/delete_user_account_test.go new file mode 100644 index 0000000..14d2184 --- /dev/null +++ b/internal/api/handlers/auth/delete_user_account_test.go @@ -0,0 +1,136 @@ +package auth_test + +import ( + "context" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func assertUserAndRelatedData(ctx context.Context, t *testing.T, s *api.Server, userID string, expectExists bool) { + t.Helper() + + userExists, err := models.Users( + models.UserWhere.ID.EQ(userID), + ).Exists(ctx, s.DB) + require.NoError(t, err) + require.Equal(t, expectExists, userExists) + + appUserProfileExists, err := models.AppUserProfiles( + models.AppUserProfileWhere.UserID.EQ(userID), + ).Exists(ctx, s.DB) + require.NoError(t, err) + require.Equal(t, expectExists, appUserProfileExists) + + accessTokenExists, err := models.AccessTokens( + models.AccessTokenWhere.UserID.EQ(userID), + ).Exists(ctx, s.DB) + require.NoError(t, err) + require.Equal(t, expectExists, accessTokenExists) + + refreshTokenExists, err := models.RefreshTokens( + models.RefreshTokenWhere.UserID.EQ(userID), + ).Exists(ctx, s.DB) + require.NoError(t, err) + require.Equal(t, expectExists, refreshTokenExists) + + pushTokenExists, err := models.PushTokens( + models.PushTokenWhere.UserID.EQ(userID), + ).Exists(ctx, s.DB) + require.NoError(t, err) + require.Equal(t, expectExists, pushTokenExists) +} + +func TestDeleteUserAccount(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + // expect the user to have a app user profile and different kinds of tokens (access, refresh, push, password reset) + assertUserAndRelatedData(ctx, t, s, fix.User1.ID, true) + + payload := test.GenericPayload{ + "currentPassword": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + // expect the user and all related data to be deleted + assertUserAndRelatedData(ctx, t, s, fix.User1.ID, false) + }) +} + +func TestDeleteUserAccountCurrentPasswordWrong(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "currentPassword": "wrongpassword", + } + + res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestDeleteUserAccountMissingCurrentPassword(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + + res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + }) +} + +func TestDeleteUserAccountNoAuth(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestDeleteUserAccountUserNotActive(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + ctx := t.Context() + + fix.User1.IsActive = false + _, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.IsActive)) + require.NoError(t, err) + + payload := test.GenericPayload{ + "currentPassword": "somepassword", + } + + res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated) + }) +} + +func TestDeleteUserAccountUserNotLocal(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + ctx := t.Context() + + fix.User1.Password = null.String{} + _, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.Password)) + require.NoError(t, err) + + payload := test.GenericPayload{ + "currentPassword": "wrongpassword", + } + + res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser) + }) +} diff --git a/internal/api/handlers/auth/get_complete_register.go b/internal/api/handlers/auth/get_complete_register.go new file mode 100644 index 0000000..414ae9d --- /dev/null +++ b/internal/api/handlers/auth/get_complete_register.go @@ -0,0 +1,39 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/router/templates" + "allaboutapps.dev/aw/go-starter/internal/types/auth" + "allaboutapps.dev/aw/go-starter/internal/util/url" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" +) + +func GetCompleteRegisterRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.GET("/register", getCompleteRegisterHandler(s)) +} + +func getCompleteRegisterHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + params := auth.NewGetCompleteRegisterRouteParams() + if err := util.BindAndValidatePathAndQueryParams(c, ¶ms); err != nil { + return err + } + + confirmationRequestURL, err := url.ConfirmationRequestURL(s.Config, params.Token.String()) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate confirmation link") + return err + } + + return c.Render(http.StatusOK, templates.ViewTemplateAccountConfirmation.String(), map[string]interface{}{ + "confirmationRequestURL": confirmationRequestURL.String(), + }) + } +} diff --git a/internal/api/handlers/auth/get_complete_register_test.go b/internal/api/handlers/auth/get_complete_register_test.go new file mode 100644 index 0000000..7e46e83 --- /dev/null +++ b/internal/api/handlers/auth/get_complete_register_test.go @@ -0,0 +1,27 @@ +package auth_test + +import ( + "fmt" + "io" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/stretchr/testify/require" +) + +func TestGetCompleteRegister(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + + res := test.PerformRequest(t, s, "GET", fmt.Sprintf("/api/v1/auth/register?token=%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + response, err := io.ReadAll(res.Body) + require.NoError(t, err) + + test.Snapshoter.SaveString(t, string(response)) + }) +} diff --git a/internal/api/handlers/auth/get_userinfo.go b/internal/api/handlers/auth/get_userinfo.go new file mode 100644 index 0000000..2f4f8ff --- /dev/null +++ b/internal/api/handlers/auth/get_userinfo.go @@ -0,0 +1,33 @@ +package auth + +import ( + "errors" + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" +) + +func GetUserInfoRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.GET("/userinfo", getUserInfoHandler(s)) +} + +func getUserInfoHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + user := auth.UserFromContext(ctx) + log := util.LogFromContext(ctx) + + var err error + + user.Profile, err = s.Auth.GetAppUserProfile(ctx, user.ID) + if err != nil && !errors.Is(err, auth.ErrNotFound) { + log.Debug().Err(err).Msg("Failed to get user profile") + return err + } + + return util.ValidateAndReturn(c, http.StatusOK, user.ToTypes()) + } +} diff --git a/internal/api/handlers/auth/get_userinfo_test.go b/internal/api/handlers/auth/get_userinfo_test.go new file mode 100644 index 0000000..ab4bf36 --- /dev/null +++ b/internal/api/handlers/auth/get_userinfo_test.go @@ -0,0 +1,69 @@ +package auth_test + +import ( + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetUserInfo(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.GetUserInfoResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.Equal(t, fix.User1.ID, *response.Sub) + assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email) + test.Snapshoter.Skip([]string{"UpdatedAt"}).Save(t, response) + + for _, scope := range fix.User1.Scopes { + assert.Contains(t, response.Scopes, scope) + } + + appUserProfile, err := models.FindAppUserProfile(ctx, s.DB, fix.User1.ID, models.AccessTokenColumns.UpdatedAt) + require.NoError(t, err) + + assert.Equal(t, appUserProfile.UpdatedAt.Unix(), *response.UpdatedAt) + }) +} + +func TestGetUserInfoMinimal(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + _, err := models.AppUserProfiles(models.AppUserProfileWhere.UserID.EQ(fix.User1.ID)).DeleteAll(ctx, s.DB) + require.NoError(t, err) + + res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.GetUserInfoResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.Equal(t, fix.User1.ID, *response.Sub) + assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email) + + for _, scope := range fix.User1.Scopes { + assert.Contains(t, response.Scopes, scope) + } + + user, err := models.FindUser(ctx, s.DB, fix.User1.ID, models.UserColumns.UpdatedAt) + require.NoError(t, err) + + assert.Equal(t, user.UpdatedAt.Unix(), *response.UpdatedAt) + }) +} diff --git a/internal/api/handlers/auth/post_change_password.go b/internal/api/handlers/auth/post_change_password.go new file mode 100644 index 0000000..a90d521 --- /dev/null +++ b/internal/api/handlers/auth/post_change_password.go @@ -0,0 +1,42 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func PostChangePasswordRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/change-password", postChangePasswordHandler(s)) +} + +func postChangePasswordHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + user := auth.UserFromEchoContext(c) + log := util.LogFromContext(ctx) + + var body types.PostChangePasswordPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + result, err := s.Auth.UpdatePassword(ctx, dto.UpdatePasswordRequest{ + User: *user, + CurrentPassword: swag.StringValue(body.CurrentPassword), + NewPassword: swag.StringValue(body.NewPassword), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to update password") + return err + } + + return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes()) + } +} diff --git a/internal/api/handlers/auth/post_change_password_test.go b/internal/api/handlers/auth/post_change_password_test.go new file mode 100644 index 0000000..5e40e35 --- /dev/null +++ b/internal/api/handlers/auth/post_change_password_test.go @@ -0,0 +1,187 @@ +package auth_test + +import ( + "database/sql" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/auth" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + newPassword = "correct horse battery staple" +) + +func TestPostChangePasswordSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "currentPassword": fixtures.PlainTestUserPassword, + "newPassword": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEqual(t, fix.User1AccessToken1.Token, *response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response.TokenType) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + + cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + err = fix.User1.Reload(ctx, s.DB) + require.NoError(t, err) + assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String) + }) +} + +func TestPostChangePasswordInvalidPassword(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "currentPassword": "not my password", + "newPassword": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostChangePasswordDeactivatedUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "currentPassword": fixtures.PlainTestUserPassword, + "newPassword": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.UserDeactivatedAccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostChangePasswordUserWithoutPassword(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "currentPassword": fixtures.PlainTestUserPassword, + "newPassword": newPassword, + } + + fix.User2.Password = null.String{} + rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer()) + require.NoError(t, err) + require.Equal(t, int64(1), rowsAff) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User2AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser) + + err = fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostChangePasswordBadRequest(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + tests := []struct { + name string + payload test.GenericPayload + }{ + { + name: "MissingCurrentPassword", + payload: test.GenericPayload{ + "newPassword": newPassword, + }, + }, + { + name: "MissingNewPassword", + payload: test.GenericPayload{ + "currentPassword": fixtures.PlainTestUserPassword, + }, + }, + { + name: "EmptyCurrentPassword", + payload: test.GenericPayload{ + "currentPassword": "", + "newPassword": newPassword, + }, + }, + { + name: "EmptyNewPassword", + payload: test.GenericPayload{ + "currentPassword": fixtures.PlainTestUserPassword, + "newPassword": "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", tt.payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) + } + }) +} diff --git a/internal/api/handlers/auth/post_complete_register.go b/internal/api/handlers/auth/post_complete_register.go new file mode 100644 index 0000000..808869c --- /dev/null +++ b/internal/api/handlers/auth/post_complete_register.go @@ -0,0 +1,40 @@ +package auth + +import ( + "fmt" + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/constants" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types/auth" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" +) + +func PostCompleteRegisterRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST(fmt.Sprintf("/register/:%s", constants.RegistrationTokenParam), postCompleteRegisterHandler(s)) +} + +func postCompleteRegisterHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + params := auth.NewPostCompleteRegisterRouteParams() + if err := util.BindAndValidatePathAndQueryParams(c, ¶ms); err != nil { + return err + } + + result, err := s.Auth.CompleteRegister(ctx, dto.CompleteRegisterRequest{ + ConfirmationToken: params.RegistrationToken.String(), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to complete registration") + return echo.ErrUnauthorized + } + + return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes()) + } +} diff --git a/internal/api/handlers/auth/post_complete_register_test.go b/internal/api/handlers/auth/post_complete_register_test.go new file mode 100644 index 0000000..03caacc --- /dev/null +++ b/internal/api/handlers/auth/post_complete_register_test.go @@ -0,0 +1,70 @@ +package auth_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostCompleteRegister(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + ctx := t.Context() + + res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + + user, err := models.Users( + models.UserWhere.ID.EQ(fix.UserRequiresConfirmationConfirmationToken.UserID), + ).One(ctx, s.DB) + require.NoError(t, err) + + assert.True(t, user.IsActive) + assert.False(t, user.RequiresConfirmation) + + // trying again with the same token should fail + { + res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + } + }) +} + +func TestPostCompleteRegisterTokenNotFound(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register/e45071b7-b9a0-4ed7-a5a0-16a16413d275", nil, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestPostCompleteRegisterTokenExpired(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + ctx := t.Context() + + fix.UserRequiresConfirmationConfirmationToken.ValidUntil = s.Clock.Now().Add(-1 * time.Second) + _, err := fix.UserRequiresConfirmationConfirmationToken.Update(ctx, s.DB, boil.Whitelist(models.ConfirmationTokenColumns.ValidUntil)) + require.NoError(t, err) + + res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} diff --git a/internal/api/handlers/auth/post_forgot_password.go b/internal/api/handlers/auth/post_forgot_password.go new file mode 100644 index 0000000..9b9f6b9 --- /dev/null +++ b/internal/api/handlers/auth/post_forgot_password.go @@ -0,0 +1,57 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/url" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +func PostForgotPasswordRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/forgot-password", postForgotPasswordHandler(s)) +} + +func postForgotPasswordHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + + var body types.PostForgotPasswordPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + username := dto.NewUsername(body.Username.String()) + + result, err := s.Auth.InitPasswordReset(ctx, dto.InitPasswordResetRequest{ + Username: username, + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to initiate password reset") + return err + } + + if result.ResetToken.IsZero() { + log.Debug().Msg("Failed to initiate password reset, no token returned") + // Return success status to prevent user enumeration + return c.NoContent(http.StatusNoContent) + } + + resetLink, err := url.PasswordResetDeeplinkURL(s.Config, result.ResetToken.String) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate password reset link") + return err + } + + if err := s.Mailer.SendPasswordReset(ctx, username.String(), resetLink.String()); err != nil { + log.Debug().Err(err).Msg("Failed to send password reset email") + return err + } + + return c.NoContent(http.StatusNoContent) + } +} diff --git a/internal/api/handlers/auth/post_forgot_password_complete.go b/internal/api/handlers/auth/post_forgot_password_complete.go new file mode 100644 index 0000000..c4da06c --- /dev/null +++ b/internal/api/handlers/auth/post_forgot_password_complete.go @@ -0,0 +1,39 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func PostForgotPasswordCompleteRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/forgot-password/complete", postForgotPasswordCompleteHandler(s)) +} + +func postForgotPasswordCompleteHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + var body types.PostForgotPasswordCompletePayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + result, err := s.Auth.ResetPassword(ctx, dto.ResetPasswordRequest{ + ResetToken: body.Token.String(), + NewPassword: swag.StringValue(body.Password), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to reset password") + return err + } + + return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes()) + } +} diff --git a/internal/api/handlers/auth/post_forgot_password_complete_test.go b/internal/api/handlers/auth/post_forgot_password_complete_test.go new file mode 100644 index 0000000..bb9043a --- /dev/null +++ b/internal/api/handlers/auth/post_forgot_password_complete_test.go @@ -0,0 +1,280 @@ +package auth_test + +import ( + "database/sql" + "net/http" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostForgotPasswordCompleteSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + passwordResetToken := models.PasswordResetToken{ + UserID: fix.User1.ID, + ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity), + } + + err := passwordResetToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + payload := test.GenericPayload{ + "token": passwordResetToken.Token, + "password": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken) + test.Snapshoter.Skip([]string{"AccessToken", "RefreshToken"}).Save(t, response) + + err = fix.User1AccessToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + + cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + err = fix.User1.Reload(ctx, s.DB) + require.NoError(t, err) + assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String) + }) +} + +func TestPostForgotPasswordCompleteUnknownToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "token": "fd5c04ea-f39c-49e9-bb40-7f570ed1f66f", + "password": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrNotFoundTokenNotFound) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + + cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + err = fix.User1.Reload(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String) + }) +} + +func TestPostForgotPasswordCompleteExpiredToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + passwordResetToken := models.PasswordResetToken{ + UserID: fix.User1.ID, + ValidUntil: s.Clock.Now().Add(time.Minute * -10), + } + + err := passwordResetToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + payload := test.GenericPayload{ + "token": passwordResetToken.Token, + "password": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrConflictTokenExpired) + + err = fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + + cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + err = fix.User1.Reload(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String) + }) +} + +func TestPostForgotPasswordCompleteDeactivatedUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + passwordResetToken := models.PasswordResetToken{ + UserID: fix.UserDeactivated.ID, + ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity), + } + + err := passwordResetToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + payload := test.GenericPayload{ + "token": passwordResetToken.Token, + "password": newPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated) + + err = fix.UserDeactivatedAccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + + cnt, err := fix.UserDeactivated.AccessTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + cnt, err = fix.UserDeactivated.RefreshTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + err = fix.UserDeactivated.Reload(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, fixtures.HashedTestUserPassword, fix.UserDeactivated.Password.String) + }) +} + +func TestPostForgotPasswordCompleteUserWithoutPassword(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + passwordResetToken := models.PasswordResetToken{ + UserID: fix.User2.ID, + ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity), + } + + err := passwordResetToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + payload := test.GenericPayload{ + "token": passwordResetToken.Token, + "password": newPassword, + } + + fix.User2.Password = null.String{} + rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer()) + require.NoError(t, err) + require.Equal(t, int64(1), rowsAff) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser) + + err = fix.User2AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + err = fix.User2RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + + cnt, err := fix.User2.AccessTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + cnt, err = fix.User2.RefreshTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(1), cnt) + + err = fix.User2.Reload(ctx, s.DB) + require.NoError(t, err) + assert.False(t, fix.User2.Password.Valid) + }) +} + +func TestPostForgotPasswordCompleteBadRequest(t *testing.T) { + tests := []struct { + name string + payload test.GenericPayload + }{ + { + name: "MissingToken", + payload: test.GenericPayload{ + "password": "correct horse battery stable", + }, + }, + { + name: "MissingPassword", + payload: test.GenericPayload{ + "token": "7b6e2366-7806-421f-bd56-ffcb39d7b1ee", + }, + }, + { + name: "InvalidToken", + payload: test.GenericPayload{ + "token": "definitelydoesnotexist", + "password": "correct horse battery stable", + }, + }, + { + name: "EmptyToken", + payload: test.GenericPayload{ + "password": "correct horse battery stable", + "token": "", + }, + }, + { + name: "EmptyPassword", + payload: test.GenericPayload{ + "token": "42deb737-fa9c-4e9e-bdce-e33b829c72f7", + "password": "", + }, + }, + } + + test.WithTestServer(t, func(s *api.Server) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", tt.payload, nil) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + }) + } + }) +} diff --git a/internal/api/handlers/auth/post_forgot_password_test.go b/internal/api/handlers/auth/post_forgot_password_test.go new file mode 100644 index 0000000..ee32c68 --- /dev/null +++ b/internal/api/handlers/auth/post_forgot_password_test.go @@ -0,0 +1,256 @@ +package auth_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostForgotPasswordSuccess(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Auth.PasswordResetTokenReuseDuration = 120 * time.Second + config.Auth.PasswordResetTokenDebounceDuration = 60 * time.Second + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fix.User1.Username, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB) + require.NoError(t, err) + + mail := test.GetLastSentMail(t, s.Mailer) + require.NotNil(t, mail) + assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token)) + + // retrying should not send a new mail because of the debounce time + { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + sentMails := test.GetSentMails(t, s.Mailer) + assert.Len(t, sentMails, 1) + } + + // CreatedAt of token exceeds debounce time, retrying should send a new mail + // but with the same token as the reuse duration has not passed yet + { + passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenDebounceDuration) + _, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt)) + require.NoError(t, err) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + sentMails := test.GetSentMails(t, s.Mailer) + require.Len(t, sentMails, 2) + + passwordResetTokens, err := fix.User1.PasswordResetTokens().All(ctx, s.DB) + require.NoError(t, err) + + assert.Len(t, passwordResetTokens, 1) + for _, mail := range sentMails { + assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token)) + } + } + + // CreatedAt of token exceeds reuse time, retrying should send a new mail with a new token + { + passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenReuseDuration) + _, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt)) + require.NoError(t, err) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + sentMails := test.GetSentMails(t, s.Mailer) + require.Len(t, sentMails, 3) + + passwordResetTokens, err := fix.User1.PasswordResetTokens( + db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt), + ).All(ctx, s.DB) + require.NoError(t, err) + + require.Len(t, passwordResetTokens, 2) + + assert.Contains(t, string(sentMails[2].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token)) + } + + // Token validity is expired, retrying should send a new mail with a new token + { + _, err = models.PasswordResetTokens().UpdateAll(ctx, s.DB, models.M{ + models.PasswordResetTokenColumns.ValidUntil: s.Clock.Now().Add(-1 * time.Second), + }) + require.NoError(t, err) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + sentMails := test.GetSentMails(t, s.Mailer) + require.Len(t, sentMails, 4) + + passwordResetTokens, err := fix.User1.PasswordResetTokens( + db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt), + ).All(ctx, s.DB) + require.NoError(t, err) + + require.Len(t, passwordResetTokens, 3) + + assert.Contains(t, string(sentMails[3].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token)) + } + }) +} + +func TestPostForgotPasswordSuccessLowercaseTrimWhitespaces(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)), + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB) + require.NoError(t, err) + + mail := test.GetLastSentMail(t, s.Mailer) + require.NotNil(t, mail) + assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token)) + }) +} + +func TestPostForgotPasswordUnknownUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + + payload := test.GenericPayload{ + "username": "definitelydoesnotexist@example.com", + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + cnt, err := models.PasswordResetTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(0), cnt) + + mail := test.GetLastSentMail(t, s.Mailer) + assert.Nil(t, mail) + }) +} + +func TestPostForgotPasswordDeactivatedUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "username": fix.UserDeactivated.Username, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + cnt, err := models.PasswordResetTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(0), cnt) + + mail := test.GetLastSentMail(t, s.Mailer) + assert.Nil(t, mail) + }) +} + +func TestPostForgotPasswordUserWithoutPassword(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "username": fix.User2.Username, + } + + fix.User2.Password = null.String{} + rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer()) + require.NoError(t, err) + require.Equal(t, int64(1), rowsAff) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + cnt, err := models.PasswordResetTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(0), cnt) + + mail := test.GetLastSentMail(t, s.Mailer) + assert.Nil(t, mail) + }) +} + +func TestPostForgotPasswordBadRequest(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + + tests := []struct { + name string + payload test.GenericPayload + }{ + { + name: "MissingUsername", + payload: test.GenericPayload{}, + }, + { + name: "EmptyUsername", + payload: test.GenericPayload{ + "username": "", + }, + }, + { + name: "InvalidUsername", + payload: test.GenericPayload{ + "username": "definitely not an email", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", tt.payload, nil) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + + cnt, err := models.PasswordResetTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, int64(0), cnt) + + mail := test.GetLastSentMail(t, s.Mailer) + assert.Nil(t, mail) + }) + } + }) +} diff --git a/internal/api/handlers/auth/post_login.go b/internal/api/handlers/auth/post_login.go new file mode 100644 index 0000000..6162616 --- /dev/null +++ b/internal/api/handlers/auth/post_login.go @@ -0,0 +1,39 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func PostLoginRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/login", postLoginHandler(s)) +} + +func postLoginHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + var body types.PostLoginPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + result, err := s.Auth.Login(ctx, dto.LoginRequest{ + Username: dto.NewUsername(body.Username.String()), + Password: swag.StringValue(body.Password), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to authenticate user") + return err + } + + return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes()) + } +} diff --git a/internal/api/handlers/auth/post_login_test.go b/internal/api/handlers/auth/post_login_test.go new file mode 100644 index 0000000..f16c082 --- /dev/null +++ b/internal/api/handlers/auth/post_login_test.go @@ -0,0 +1,179 @@ +package auth_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostLoginSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fix.User1.Username, + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response.TokenType) + }) +} + +func TestPostLoginSuccessLowercaseTrimWhitespaces(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)), + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response.TokenType) + }) +} + +func TestPostLoginInvalidCredentials(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fix.User1.Username, + "password": "not my password", + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestPostLoginUnknownUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + payload := test.GenericPayload{ + "username": "definitelydoesnotexist@example.com", + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestPostLoginDeactivatedUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fix.UserDeactivated.Username, + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated) + }) +} + +func TestPostLoginUserWithoutPassword(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fix.User2.Username, + "password": fixtures.PlainTestUserPassword, + } + + fix.User2.Password = null.String{} + rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer()) + require.NoError(t, err) + require.Equal(t, int64(1), rowsAff) + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestPostLoginBadRequest(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + + tests := []struct { + name string + payload test.GenericPayload + }{ + { + name: "InvalidUsername", + payload: test.GenericPayload{ + "username": "definitely not an email", + "password": fixtures.PlainTestUserPassword, + }, + }, + { + name: "MissingUsername", + payload: test.GenericPayload{ + "password": fixtures.PlainTestUserPassword, + }, + }, + { + name: "MissingPassword", + payload: test.GenericPayload{ + "username": fix.User1.Username, + }, + }, + { + name: "EmptyUsername", + payload: test.GenericPayload{ + "username": "", + "password": fixtures.PlainTestUserPassword, + }, + }, + { + name: "EmptyPassword", + payload: test.GenericPayload{ + "username": fix.User1.Username, + "password": "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", tt.payload, nil) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + }) + } + }) +} diff --git a/internal/api/handlers/auth/post_logout.go b/internal/api/handlers/auth/post_logout.go new file mode 100644 index 0000000..474699a --- /dev/null +++ b/internal/api/handlers/auth/post_logout.go @@ -0,0 +1,45 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/null/v8" + "github.com/labstack/echo/v4" +) + +func PostLogoutRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/logout", postLogoutHandler(s)) +} + +func postLogoutHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + var body types.PostLogoutPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + request := dto.LogoutRequest{ + AccessToken: *auth.AccessTokenFromEchoContext(c), + } + + if len(body.RefreshToken.String()) > 0 { + request.RefreshToken = null.StringFrom(body.RefreshToken.String()) + } + + err := s.Auth.Logout(ctx, request) + if err != nil { + log.Debug().Err(err).Msg("Failed to logout user") + return err + } + + return c.NoContent(http.StatusNoContent) + } +} diff --git a/internal/api/handlers/auth/post_logout_test.go b/internal/api/handlers/auth/post_logout_test.go new file mode 100644 index 0000000..b7bf75d --- /dev/null +++ b/internal/api/handlers/auth/post_logout_test.go @@ -0,0 +1,129 @@ +package auth_test + +import ( + "database/sql" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/api/middleware" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostLogoutSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostLogoutSuccessWithRefreshToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "refresh_token": fix.User1RefreshToken1.Token, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + }) +} + +func TestPostLogoutSuccessWithUnknownRefreshToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + payload := test.GenericPayload{ + "refresh_token": "93d8ccd0-be30-4661-a428-cbe74e1a3ffe", + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostLogoutInvalidRefreshToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "refresh_token": "not my refresh token", + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + + err := fix.User1AccessToken1.Reload(ctx, s.DB) + require.NoError(t, err) + + err = fix.User1RefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostLogoutError(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + tests := []struct { + name string + expectedError *httperrors.HTTPError + headers http.Header + }{ + { + name: "InvalidAuthToken", + expectedError: middleware.ErrBadRequestMalformedToken, + headers: test.HeadersWithAuth(t, "not my auth token"), + }, + { + name: "UnknownAuthToken", + expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized), + headers: test.HeadersWithAuth(t, "25e8630e-9a41-4f38-8339-373f0c203cef"), + }, + { + name: "MissingAuthToken", + expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized), + headers: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, tt.headers) + test.RequireHTTPError(t, res, tt.expectedError) + }) + } + }) +} diff --git a/internal/api/handlers/auth/post_refresh.go b/internal/api/handlers/auth/post_refresh.go new file mode 100644 index 0000000..135b417 --- /dev/null +++ b/internal/api/handlers/auth/post_refresh.go @@ -0,0 +1,37 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" +) + +func PostRefreshRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/refresh", postRefreshHandler(s)) +} + +func postRefreshHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + var body types.PostRefreshPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + result, err := s.Auth.Refresh(ctx, dto.RefreshRequest{ + RefreshToken: body.RefreshToken.String(), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to refresh tokens") + return err + } + + return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes()) + } +} diff --git a/internal/api/handlers/auth/post_refresh_test.go b/internal/api/handlers/auth/post_refresh_test.go new file mode 100644 index 0000000..0847456 --- /dev/null +++ b/internal/api/handlers/auth/post_refresh_test.go @@ -0,0 +1,108 @@ +package auth_test + +import ( + "database/sql" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostRefreshSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "refresh_token": fix.User1RefreshToken1.Token, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response.TokenType) + + err := fix.User1RefreshToken1.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + }) +} + +func TestPostRefreshUnknownToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + payload := test.GenericPayload{ + "refresh_token": "c094e933-e5f0-4ece-9c10-914f3122cdb6", + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized)) + }) +} + +func TestPostRefreshDeactivatedUser(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "refresh_token": fix.UserDeactivatedRefreshToken1.Token, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated) + + err := fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB) + require.NoError(t, err) + }) +} + +func TestPostRefreshBadRequest(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + tests := []struct { + name string + payload test.GenericPayload + }{ + { + name: "MissingRefreshToken", + payload: test.GenericPayload{}, + }, + { + name: "EmptyRefreshToken", + payload: test.GenericPayload{ + "refresh_token": "", + }, + }, + { + name: "InvalidToken", + payload: test.GenericPayload{ + "refresh_token": "not a valid token", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", tt.payload, nil) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + }) + } + }) +} diff --git a/internal/api/handlers/auth/post_register.go b/internal/api/handlers/auth/post_register.go new file mode 100644 index 0000000..cbeaee0 --- /dev/null +++ b/internal/api/handlers/auth/post_register.go @@ -0,0 +1,72 @@ +package auth + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/url" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func PostRegisterRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Auth.POST("/register", postRegisterHandler(s)) +} + +func postRegisterHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + log := util.LogFromContext(ctx) + + var body types.PostRegisterPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + username := dto.NewUsername(body.Username.String()) + + result, err := s.Auth.Register(ctx, dto.RegisterRequest{ + Username: username, + Password: swag.StringValue(body.Password), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to register user") + return err + } + + if !result.RequiresConfirmation { + loginResult, err := s.Auth.Login(ctx, dto.LoginRequest{ + Username: username, + Password: swag.StringValue(body.Password), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to authenticate user after registration") + return err + } + + return util.ValidateAndReturn(c, http.StatusOK, loginResult.ToTypes()) + } + + if result.ConfirmationToken.Valid { + confirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, result.ConfirmationToken.String) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate confirmation link") + return err + } + + if err := s.Mailer.SendAccountConfirmation(ctx, username.String(), dto.ConfirmatioNotificationPayload{ + ConfirmationLink: confirmationLink.String(), + }); err != nil { + log.Debug().Err(err).Msg("Failed to send confirmation email") + return err + } + } + + return util.ValidateAndReturn(c, http.StatusAccepted, &types.RegisterResponse{ + RequiresConfirmation: swag.Bool(result.RequiresConfirmation), + }) + } +} diff --git a/internal/api/handlers/auth/post_register_test.go b/internal/api/handlers/auth/post_register_test.go new file mode 100644 index 0000000..6ff42b3 --- /dev/null +++ b/internal/api/handlers/auth/post_register_test.go @@ -0,0 +1,334 @@ +package auth_test + +import ( + "net/http" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "allaboutapps.dev/aw/go-starter/internal/util/url" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostRegisterSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + + now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC) + test.SetMockClock(t, s, now) + + username := "usernew@example.com" + payload := test.GenericPayload{ + "username": username, + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response.TokenType) + + user, err := models.Users( + models.UserWhere.Username.EQ(null.StringFrom(username)), + qm.Load(models.UserRels.AppUserProfile), + qm.Load(models.UserRels.AccessTokens), + qm.Load(models.UserRels.RefreshTokens), + ).One(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(username), user.Username) + assert.True(t, user.LastAuthenticatedAt.Valid) + assert.Equal(t, now, user.LastAuthenticatedAt.Time) + assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes) + + assert.NotNil(t, user.R.AppUserProfile) + assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid) + + assert.Len(t, user.R.AccessTokens, 1) + assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken) + assert.Len(t, user.R.RefreshTokens, 1) + assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken) + + res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + assert.Equal(t, http.StatusOK, res2.Result().StatusCode) + + var response2 types.PostLoginResponse + test.ParseResponseAndValidate(t, res2, &response2) + + assert.NotEmpty(t, response2.AccessToken) + assert.NotEqual(t, response.AccessToken, *response2.AccessToken) + assert.NotEmpty(t, response2.RefreshToken) + assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType) + }) +} + +func TestPostRegisterWithConfirmationSuccess(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Auth.RegistrationRequiresConfirmation = true + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + ctx := t.Context() + + username := "usernew-with-confirmation@example.com" + payload := test.GenericPayload{ + "username": username, + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil) + + require.Equal(t, http.StatusAccepted, res.Result().StatusCode) + + var response types.RegisterResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.True(t, swag.BoolValue(response.RequiresConfirmation)) + + // expect the user to be normally created + user, err := models.Users( + models.UserWhere.Username.EQ(null.StringFrom(username)), + qm.Load(models.UserRels.AppUserProfile), + qm.Load(models.UserRels.AccessTokens), + qm.Load(models.UserRels.RefreshTokens), + qm.Load(models.UserRels.ConfirmationTokens), + ).One(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(username), user.Username) + assert.True(t, user.LastAuthenticatedAt.Valid) + assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes) + assert.False(t, user.IsActive) + assert.True(t, user.RequiresConfirmation) + + assert.NotNil(t, user.R.AppUserProfile) + assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid) + + // expect the user to have no access or refresh tokens + assert.Empty(t, user.R.AccessTokens) + assert.Empty(t, user.R.RefreshTokens) + require.Len(t, user.R.ConfirmationTokens, 1) + confirmationToken := user.R.ConfirmationTokens[0] + + // expect the login to fail + res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + test.RequireHTTPError(t, res2, httperrors.ErrForbiddenUserDeactivated) + + // expect the confirmation email to be sent + mails := test.GetSentMails(t, s.Mailer) + require.Len(t, mails, 1) + + mail := mails[0] + expectedConfirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, confirmationToken.Token) + require.NoError(t, err) + + assert.Equal(t, username, mail.To[0]) + assert.Contains(t, string(mail.HTML), expectedConfirmationLink.String()) + + // directly register again should trigger the debounce + // and not create a new confirmation token + registerAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil) + require.Equal(t, http.StatusAccepted, registerAgainRes.Result().StatusCode) + + var registerAgainResponse types.RegisterResponse + test.ParseResponseAndValidate(t, registerAgainRes, ®isterAgainResponse) + + // expect the confirmation to be required + assert.True(t, swag.BoolValue(registerAgainResponse.RequiresConfirmation)) + + confirmationTokenCount, err := user.ConfirmationTokens().Count(ctx, s.DB) + require.NoError(t, err) + + assert.EqualValues(t, 1, confirmationTokenCount) + + // register later again + test.SetMockClock(t, s, s.Clock.Now().Add(config.Auth.ConfirmationTokenDebounceDuration+time.Second)) + + registerLaterAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil) + require.Equal(t, http.StatusAccepted, registerLaterAgainRes.Result().StatusCode) + + var registerLaterAgainResponse types.RegisterResponse + test.ParseResponseAndValidate(t, registerLaterAgainRes, ®isterLaterAgainResponse) + assert.True(t, swag.BoolValue(registerLaterAgainResponse.RequiresConfirmation)) + + confirmationTokens, err := models.ConfirmationTokens( + models.ConfirmationTokenWhere.UserID.EQ(user.ID), + db.OrderBy(types.OrderDirDesc, models.ConfirmationTokenColumns.CreatedAt), + ).All(ctx, s.DB) + require.NoError(t, err) + + require.Len(t, confirmationTokens, 2) + + lastSentMail := test.GetLastSentMail(t, s.Mailer) + require.NotNil(t, lastSentMail) + + expectedConfirmationLink, err = url.ConfirmationDeeplinkURL(s.Config, confirmationTokens[0].Token) + require.NoError(t, err) + + assert.Equal(t, username, lastSentMail.To[0]) + assert.Contains(t, string(lastSentMail.HTML), expectedConfirmationLink.String()) + }) +} + +func TestPostRegisterSuccessLowercaseTrimWhitespaces(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + + username := " USERNEW@example.com " + usernameLowerTrimmed := "usernew@example.com" + payload := test.GenericPayload{ + "username": username, + "password": fixtures.PlainTestUserPassword, + "name": "Trim Whitespaces", + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.NotEmpty(t, response.AccessToken) + assert.NotEmpty(t, response.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response.TokenType) + + user, err := models.Users( + models.UserWhere.Username.EQ(null.StringFrom(usernameLowerTrimmed)), + qm.Load(models.UserRels.AppUserProfile), + qm.Load(models.UserRels.AccessTokens), + qm.Load(models.UserRels.RefreshTokens), + ).One(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(usernameLowerTrimmed), user.Username) + assert.True(t, user.LastAuthenticatedAt.Valid) + assert.WithinDuration(t, s.Clock.Now(), user.LastAuthenticatedAt.Time, time.Second*10) + assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes) + + assert.NotNil(t, user.R.AppUserProfile) + assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid) + + assert.Len(t, user.R.AccessTokens, 1) + assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken) + assert.Len(t, user.R.RefreshTokens, 1) + assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken) + + res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil) + assert.Equal(t, http.StatusOK, res2.Result().StatusCode) + + var response2 types.PostLoginResponse + test.ParseResponseAndValidate(t, res2, &response2) + + assert.NotEmpty(t, response2.AccessToken) + assert.NotEqual(t, response.AccessToken, *response2.AccessToken) + assert.NotEmpty(t, response2.RefreshToken) + assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken) + assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn) + assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType) + }) +} + +func TestPostRegisterAlreadyExists(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + + fix := fixtures.Fixtures() + payload := test.GenericPayload{ + "username": fix.User1.Username, + "password": fixtures.PlainTestUserPassword, + } + + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil) + test.RequireHTTPError(t, res, httperrors.ErrConflictUserAlreadyExists) + + user, err := models.Users( + models.UserWhere.Username.EQ(fix.User1.Username), + qm.Load(models.UserRels.AppUserProfile), + qm.Load(models.UserRels.AccessTokens), + qm.Load(models.UserRels.RefreshTokens), + ).One(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, user.ID, fix.User1.ID) + + assert.NotNil(t, user.R.AppUserProfile) + assert.Len(t, user.R.AccessTokens, 1) + assert.Len(t, user.R.RefreshTokens, 1) + }) +} + +func TestPostRegisterBadRequest(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + + tests := []struct { + name string + payload test.GenericPayload + }{ + { + name: "MissingUsername", + payload: test.GenericPayload{ + "password": fixtures.PlainTestUserPassword, + }, + }, + { + name: "MissingPassword", + payload: test.GenericPayload{ + "username": fix.User1.Username, + }, + }, + { + name: "InvalidUsername", + payload: test.GenericPayload{ + "username": "definitely not an email", + "password": fixtures.PlainTestUserPassword, + }, + }, + { + name: "EmptyUsername", + payload: test.GenericPayload{ + "username": "", + "password": fixtures.PlainTestUserPassword, + }, + }, + { + name: "EmptyPassword", + payload: test.GenericPayload{ + "username": fix.User1.Username, + "password": "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", tt.payload, nil) + assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + + var response httperrors.HTTPValidationError + test.ParseResponseAndValidate(t, res, &response) + + test.Snapshoter.Save(t, response) + }) + } + }) +} diff --git a/internal/api/handlers/common/get_healthy.go b/internal/api/handlers/common/get_healthy.go new file mode 100644 index 0000000..57b55f4 --- /dev/null +++ b/internal/api/handlers/common/get_healthy.go @@ -0,0 +1,55 @@ +// nolint:revive +package common + +import ( + "context" + "fmt" + "net/http" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/api" + "github.com/labstack/echo/v4" +) + +const ( + // We use 521 to indicate an error state + // same as Cloudflare: https://support.cloudflare.com/hc/en-us/articles/115003011431#521error + httpStatusDown = 521 +) + +func GetHealthyRoute(s *api.Server) *echo.Route { + return s.Router.Management.GET("/healthy", getHealthyHandler(s)) +} + +// Heathly check (= liveness) +// Returns an human readable string about the current service status. +// In addition to readiness probes, it performs actual write probes. +// Note that /-/healthy is private (shielded by the mgmt-secret) as it may expose sensitive information about your service. +// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/ +func getHealthyHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + if !s.Ready() { + return c.String(httpStatusDown, "Not ready.") + } + + var str strings.Builder + fmt.Fprintln(&str, "Ready.") + + // General Timeout and associated context. + ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.LivenessTimeout) + defer cancel() + + healthyStr, errs := ProbeLiveness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs, s.Config.Management.ProbeWriteableTouchfile) + str.WriteString(healthyStr) + + // Finally return the health status according to the seen states + if ctx.Err() != nil || len(errs) != 0 { + fmt.Fprintln(&str, "Probes failed.") + return c.String(httpStatusDown, str.String()) + } + + fmt.Fprintln(&str, "Probes succeeded.") + + return c.String(http.StatusOK, str.String()) + } +} diff --git a/internal/api/handlers/common/get_healthy_test.go b/internal/api/handlers/common/get_healthy_test.go new file mode 100644 index 0000000..1e1742f --- /dev/null +++ b/internal/api/handlers/common/get_healthy_test.go @@ -0,0 +1,110 @@ +package common_test + +import ( + "net/http" + "os" + "path" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetHealthySuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // explicitly set touchfile that no other test has (so we can explicitly remove it beforehand.) + s.Config.Management.ProbeWriteableTouchfile = ".healthy-test" + + for _, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs { + os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)) + + // also remove after test completion. + defer os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)) + } + + res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "seq_health=1") + + firstTouchTime := make([]time.Time, len(s.Config.Management.ProbeWriteablePathsAbs)) + + // expect a new touchfiles were written + for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs { + filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile) + stat, err := os.Stat(filePath) + require.NoErrorf(t, err, "Expected to have %v", filePath) + firstTouchTime[i] = stat.ModTime() + } + + res = test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "seq_health=2") + + // expect touchfiles modTime was updated + for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs { + filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile) + stat, err := os.Stat(filePath) + require.NoErrorf(t, err, "Expected to have %v", filePath) + + assert.NotEqual(t, firstTouchTime[i], stat.ModTime()) + } + }) +} + +func TestGetHealthyNoAuth(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/-/healthy", nil, nil) + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + }) +} + +func TestGetHealthyWrongAuth(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret=i-have-no-idea-about-the-pass", nil, nil) + require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) + }) +} + +func TestGetHealthyDBPingError(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // forcefully close the DB + s.DB.Close() + + res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 521, res.Result().StatusCode) + }) +} + +func TestGetHealthyDBSeqError(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // forcefully remove the sequence + if _, err := s.DB.Exec("DROP SEQUENCE seq_health;"); err != nil { + t.Fatal(err, "was unable to drop sequence seq_health") + } + + res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 521, res.Result().StatusCode) + }) +} + +func TestGetHealthyMountError(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + s.Config.Management.ProbeWriteablePathsAbs = []string{"/this/path/does/not/exist"} + + res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 521, res.Result().StatusCode) + }) +} + +func TestGetHealthyNotReady(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // forcefully remove an initialized component to check if ready state works + s.Mailer = nil + + res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 521, res.Result().StatusCode) + }) +} diff --git a/internal/api/handlers/common/get_ready.go b/internal/api/handlers/common/get_ready.go new file mode 100644 index 0000000..fbf8167 --- /dev/null +++ b/internal/api/handlers/common/get_ready.go @@ -0,0 +1,40 @@ +// nolint:revive +package common + +import ( + "context" + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "github.com/labstack/echo/v4" +) + +func GetReadyRoute(s *api.Server) *echo.Route { + return s.Router.Management.GET("/ready", getReadyHandler(s)) +} + +// Readiness check +// This endpoint returns 200 when our Service is ready to serve traffic (i.e. respond to queries). +// Does read-only probes apart from the general server ready state. +// Note that /-/ready is typically public (and not shielded by a mgmt-secret), we thus prevent information leakage here and only return `"Ready."`. +// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/ +func getReadyHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + if !s.Ready() { + return c.String(httpStatusDown, "Not ready.") + } + + // General Timeout and associated context. + ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.ReadinessTimeout) + defer cancel() + + _, errs := ProbeReadiness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs) + + // Finally return the health status according to the seen states + if ctx.Err() != nil || len(errs) != 0 { + return c.String(httpStatusDown, "Not ready.") + } + + return c.String(http.StatusOK, "Ready.") + } +} diff --git a/internal/api/handlers/common/get_ready_test.go b/internal/api/handlers/common/get_ready_test.go new file mode 100644 index 0000000..1319229 --- /dev/null +++ b/internal/api/handlers/common/get_ready_test.go @@ -0,0 +1,41 @@ +package common_test + +import ( + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/stretchr/testify/require" +) + +func TestGetReadyReadiness(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Equal(t, "Ready.", res.Body.String()) + }) +} + +func TestGetReadyReadinessBroken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // forcefully remove an initialized component to check if ready state works + s.Mailer = nil + + res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil) + require.Equal(t, 521, res.Result().StatusCode) + require.Equal(t, "Not ready.", res.Body.String()) + }) +} + +func TestGetReadyDBBrokenNotReady(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // forcefully remove pg + err := s.DB.Close() + require.NoError(t, err) + + res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil) + require.Equal(t, 521, res.Result().StatusCode) + require.Equal(t, "Not ready.", res.Body.String()) + }) +} diff --git a/internal/api/handlers/common/get_swagger.go b/internal/api/handlers/common/get_swagger.go new file mode 100644 index 0000000..76321ab --- /dev/null +++ b/internal/api/handlers/common/get_swagger.go @@ -0,0 +1,20 @@ +// nolint:revive +package common + +import ( + "path/filepath" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/middleware" + "github.com/labstack/echo/v4" +) + +func GetSwaggerRoute(s *api.Server) *echo.Route { + // --- + // Serve generated swagger.yml file statically at /swagger.yml + // hack: not attached to group - can go away after echo/group.go .File and .Static actually return the *echo.Route + // see https://github.com/labstack/echo/issues/1595 + // return s.Router.Root.File("swagger.yml", filepath.Join(s.Config.Echo.APIBaseDirAbs, "swagger.yml")) + // we explicitly enforce a no-cache directive on any requests to it. + return s.Echo.File("/swagger.yml", filepath.Join(s.Config.Paths.APIBaseDirAbs, "swagger.yml"), middleware.NoCache()) +} diff --git a/internal/api/handlers/common/get_swagger_test.go b/internal/api/handlers/common/get_swagger_test.go new file mode 100644 index 0000000..47c7bf7 --- /dev/null +++ b/internal/api/handlers/common/get_swagger_test.go @@ -0,0 +1,32 @@ +package common_test + +import ( + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSwaggerYAMLRetrieval(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/swagger.yml", nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + // caching: ensure this call is always uncached for browsers + assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control")) + assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires")) + assert.Equal(t, "0", res.Header().Get("X-Accel-Expires")) + assert.Equal(t, "no-cache", res.Header().Get("Pragma")) + + // caching: unset + assert.Empty(t, res.Header().Get("ETag")) + assert.Empty(t, res.Header().Get("If-Modified-Since")) + assert.Empty(t, res.Header().Get("If-Match")) + assert.Empty(t, res.Header().Get("If-None-Match")) + assert.Empty(t, res.Header().Get("If-Range")) + assert.Empty(t, res.Header().Get("If-Unmodified-Since")) + }) +} diff --git a/internal/api/handlers/common/get_version.go b/internal/api/handlers/common/get_version.go new file mode 100644 index 0000000..9a1e5af --- /dev/null +++ b/internal/api/handlers/common/get_version.go @@ -0,0 +1,21 @@ +// nolint:revive +package common + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + "github.com/labstack/echo/v4" +) + +func GetVersionRoute(s *api.Server) *echo.Route { + return s.Router.Management.GET("/version", getVersionHandler(s)) +} + +// Returns the version and build date baked into the binary. +func getVersionHandler(_ *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + return c.String(http.StatusOK, config.GetFormattedBuildArgs()) + } +} diff --git a/internal/api/handlers/common/get_version_test.go b/internal/api/handlers/common/get_version_test.go new file mode 100644 index 0000000..186e34c --- /dev/null +++ b/internal/api/handlers/common/get_version_test.go @@ -0,0 +1,33 @@ +package common_test + +import ( + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetVersion(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/-/version?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Equal(t, "build.local/misses/ldflags @ < 40 chars git commit hash via ldflags > (1970-01-01T00:00:00+00:00)", res.Body.String()) // build args are not injected during test time. + + // caching: ensure this call is always uncached for browsers + assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control")) + assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires")) + assert.Equal(t, "0", res.Header().Get("X-Accel-Expires")) + assert.Equal(t, "no-cache", res.Header().Get("Pragma")) + + // caching: unset + assert.Empty(t, res.Header().Get("ETag")) + assert.Empty(t, res.Header().Get("If-Modified-Since")) + assert.Empty(t, res.Header().Get("If-Match")) + assert.Empty(t, res.Header().Get("If-None-Match")) + assert.Empty(t, res.Header().Get("If-Range")) + assert.Empty(t, res.Header().Get("If-Unmodified-Since")) + }) +} diff --git a/internal/api/handlers/common/probes.go b/internal/api/handlers/common/probes.go new file mode 100644 index 0000000..b3aab8b --- /dev/null +++ b/internal/api/handlers/common/probes.go @@ -0,0 +1,226 @@ +// nolint:revive +package common + +import ( + "context" + "database/sql" + "fmt" + "path" + "strings" + "sync" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "golang.org/x/sys/unix" +) + +func ProbeReadiness(ctx context.Context, database *sql.DB, writeablePaths []string) (string, []error) { + var str strings.Builder + + // slice collects all errors from probes + errs := make([]error, 0, 1+len(writeablePaths)) + + // DB readable? + dbPingStr, dbPingErr := probeDatabasePingable(ctx, database) + str.WriteString(dbPingStr) + + if dbPingErr != nil { + errs = append(errs, dbPingErr) + } + + // FS (potentially) writeable? + for _, writeablePath := range writeablePaths { + fsPermStr, fsPermErr := probePathWriteablePermission(ctx, writeablePath) + str.WriteString(fsPermStr) + + if fsPermErr != nil { + errs = append(errs, fsPermErr) + } + } + + // Feel free to add additional probes here... + + return str.String(), errs +} + +func ProbeLiveness(ctx context.Context, database *sql.DB, writeablePaths []string, touch string) (string, []error) { + // fail immediately if any readiness probes above have already failed. + readinessProbeStr, readinessProbeErrs := ProbeReadiness(ctx, database, writeablePaths) + + if len(readinessProbeErrs) != 0 { + return readinessProbeStr, readinessProbeErrs + } + + var str strings.Builder + + // include previous readiness probe results in final string + str.WriteString(readinessProbeStr) + + // slice collects all errors from probes + errs := make([]error, 0, 1+len(writeablePaths)) + + // DB writeable? + dbHealthStr, dbHealthErr := probeDatabaseNextHealthSequence(ctx, database) + str.WriteString(dbHealthStr) + + if dbHealthErr != nil { + errs = append(errs, dbHealthErr) + } + + // FS writeable? + for _, writeablePath := range writeablePaths { + fsTouchStr, fsTouchErr := probePathWriteableTouch(ctx, writeablePath, touch) + str.WriteString(fsTouchStr) + if fsTouchErr != nil { + errs = append(errs, fsTouchErr) + } + } + + // Feel free to add additional probes here... + + return str.String(), errs +} + +// FS (especially hard mounted NFS paths) or PostgreSQL calls may be blocking or running for too long and thus need to run detached +// We additionally want them to timeout (e.g. useful for hard mounted NFS paths) +// Typically a any context used here will already have a deadline associated +// If not we will explicitly return a short one here. +func ensureProbeDeadlineFromContext(ctx context.Context) time.Time { + ctxDeadline, hasDeadline := ctx.Deadline() + if !hasDeadline { + ctxDeadline = time.Now().Add(1 * time.Second) + } + + return ctxDeadline +} + +func probeDatabasePingable(ctx context.Context, database *sql.DB) (string, error) { + var str strings.Builder + ctxDeadline := ensureProbeDeadlineFromContext(ctx) + + dbPingStart := time.Now() + + var dbPingWg sync.WaitGroup + var dbErr error + + dbPingWg.Add(1) + go func() { + dbErr = database.PingContext(ctx) + dbPingWg.Done() + }() + + if err := util.WaitTimeout(&dbPingWg, time.Until(ctxDeadline)); err != nil { + fmt.Fprintf(&str, "Probe db: Ping deadline after %s, error=%v.\n", time.Since(dbPingStart), err.Error()) + return str.String(), err + } + + if dbErr != nil { + fmt.Fprintf(&str, "Probe db: Ping errored after %s, error=%v.\n", time.Since(dbPingStart), dbErr.Error()) + return str.String(), dbErr + } + + fmt.Fprintf(&str, "Probe db: Ping succeeded in %s.\n", time.Since(dbPingStart)) + + return str.String(), nil +} + +func probeDatabaseNextHealthSequence(ctx context.Context, database *sql.DB) (string, error) { + var str strings.Builder + ctxDeadline := ensureProbeDeadlineFromContext(ctx) + + dbWriteStart := time.Now() + + var seqVal int + var dbWriteWg sync.WaitGroup + var dbErr error + + dbWriteWg.Add(1) + go func() { + dbErr = database.QueryRowContext(ctx, "SELECT nextval('seq_health');").Scan(&seqVal) + dbWriteWg.Done() + }() + + if err := util.WaitTimeout(&dbWriteWg, time.Until(ctxDeadline)); err != nil { + fmt.Fprintf(&str, "Probe db: Next health sequence deadline after %s, error=%v.\n", time.Since(dbWriteStart), err.Error()) + return str.String(), err + } + + if dbErr != nil { + fmt.Fprintf(&str, "Probe db: Next health sequence errored after %s, error=%v.\n", time.Since(dbWriteStart), dbErr.Error()) + return str.String(), dbErr + } + + fmt.Fprintf(&str, "Probe db: Next health sequence succeeded in %s, seq_health=%v.\n", time.Since(dbWriteStart), seqVal) + + return str.String(), nil +} + +func probePathWriteablePermission(ctx context.Context, writeablePath string) (string, error) { + var str strings.Builder + ctxDeadline := ensureProbeDeadlineFromContext(ctx) + + fsWriteStart := time.Now() + + if ctx.Err() != nil { + fmt.Fprintf(&str, "Probe path '%s': W_OK check cancelled after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), ctx.Err()) + return str.String(), ctx.Err() + } + + var fsWriteWg sync.WaitGroup + var fsWriteErr error + fsWriteWg.Add(1) + go func(wp string) { + fsWriteErr = unix.Access(wp, unix.W_OK) + fsWriteWg.Done() + }(writeablePath) + + if err := util.WaitTimeout(&fsWriteWg, time.Until(ctxDeadline)); err != nil { + fmt.Fprintf(&str, "Probe path '%s': W_OK check deadline after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), err) + return str.String(), err + } + + if fsWriteErr != nil { + fmt.Fprintf(&str, "Probe path '%s': W_OK check errored after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), fsWriteErr.Error()) + return str.String(), fsWriteErr + } + + fmt.Fprintf(&str, "Probe path '%s': W_OK check succeeded in %s.\n", writeablePath, time.Since(fsWriteStart)) + + return str.String(), nil +} + +func probePathWriteableTouch(ctx context.Context, writeablePath string, touch string) (string, error) { + var str strings.Builder + ctxDeadline := ensureProbeDeadlineFromContext(ctx) + + fsTouchStart := time.Now() + fsTouchNameAbs := path.Join(writeablePath, touch) + + if ctx.Err() != nil { + fmt.Fprintf(&str, "Probe path '%s': Touch cancelled after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), ctx.Err()) + return str.String(), ctx.Err() + } + + var fsTouchWg sync.WaitGroup + var fsTouchErr error + var fsTouchModTime time.Time + fsTouchWg.Add(1) + go func(tn string) { + fsTouchModTime, fsTouchErr = util.TouchFile(tn) + fsTouchWg.Done() + }(fsTouchNameAbs) + + if err := util.WaitTimeout(&fsTouchWg, time.Until(ctxDeadline)); err != nil { + fmt.Fprintf(&str, "Probe path '%s': Touch deadline after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), err) + return str.String(), err + } + + if fsTouchErr != nil { + fmt.Fprintf(&str, "Probe path '%s': Touch errored after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchErr.Error()) + return str.String(), fsTouchErr + } + + fmt.Fprintf(&str, "Probe path '%s': Touch succeeded in %s, modTime=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchModTime.Unix()) + + return str.String(), nil +} diff --git a/internal/api/handlers/common/probes_internal_test.go b/internal/api/handlers/common/probes_internal_test.go new file mode 100644 index 0000000..15e98a3 --- /dev/null +++ b/internal/api/handlers/common/probes_internal_test.go @@ -0,0 +1,70 @@ +// nolint:revive +package common + +import ( + "context" + "database/sql" + "errors" + "os" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnsureDeadline(t *testing.T) { + deadline := time.Now().Add(1 * time.Second) + + ctx, cancel := context.WithDeadline(t.Context(), deadline) + defer cancel() + + receivedDeadline := ensureProbeDeadlineFromContext(ctx) + assert.Equal(t, deadline, receivedDeadline) +} + +func TestDummyDeadlineWithinOneSec(t *testing.T) { + ctx := t.Context() + + receivedDeadline := ensureProbeDeadlineFromContext(ctx) + assert.WithinDuration(t, time.Now().Add(1*time.Second), receivedDeadline, 100*time.Millisecond) +} + +func TestProbeDatabasePingableDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(t.Context(), time.Now()) + defer cancel() + + _, err := probeDatabasePingable(ctx, &sql.DB{}) + assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err) +} + +func TestProbeDatabaseNextHealthSequenceDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(t.Context(), time.Now()) + defer cancel() + + _, err := probeDatabaseNextHealthSequence(ctx, &sql.DB{}) + assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err) +} + +func TestProbePathWriteablePermissionContextDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(t.Context(), time.Now()) + defer cancel() + + _, err := probePathWriteablePermission(ctx, "/any/thing") + assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err) +} + +func TestProbePathWriteableTouchContextDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(t.Context(), time.Now()) + defer cancel() + + _, err := probePathWriteableTouch(ctx, "/any/thing", ".touch") + assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err) +} + +func TestProbePathWriteableTouchInaccessable(t *testing.T) { + _, err := probePathWriteableTouch(t.Context(), "/this/path/does/not/exist", ".touch") + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/internal/api/handlers/constants/constants.go b/internal/api/handlers/constants/constants.go new file mode 100644 index 0000000..f48cf5f --- /dev/null +++ b/internal/api/handlers/constants/constants.go @@ -0,0 +1,5 @@ +package constants + +const ( + RegistrationTokenParam = "registrationToken" +) diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..8be2910 --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,35 @@ +// Code generated by go run -tags scripts scripts/handlers/gen_handlers.go; DO NOT EDIT. +package handlers + +import ( + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/auth" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/common" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/push" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/wellknown" + "github.com/labstack/echo/v4" +) + +func AttachAllRoutes(s *api.Server) { + // attach our routes + s.Router.Routes = []*echo.Route{ + auth.DeleteUserAccountRoute(s), + auth.GetCompleteRegisterRoute(s), + auth.GetUserInfoRoute(s), + auth.PostChangePasswordRoute(s), + auth.PostCompleteRegisterRoute(s), + auth.PostForgotPasswordCompleteRoute(s), + auth.PostForgotPasswordRoute(s), + auth.PostLoginRoute(s), + auth.PostLogoutRoute(s), + auth.PostRefreshRoute(s), + auth.PostRegisterRoute(s), + common.GetHealthyRoute(s), + common.GetReadyRoute(s), + common.GetSwaggerRoute(s), + common.GetVersionRoute(s), + push.PutUpdatePushTokenRoute(s), + wellknown.GetAndroidDigitalAssetLinksRoute(s), + wellknown.GetAppleAppSiteAssociationRoute(s), + } +} diff --git a/internal/api/handlers/push/put_update_push_token.go b/internal/api/handlers/push/put_update_push_token.go new file mode 100644 index 0000000..67771ef --- /dev/null +++ b/internal/api/handlers/push/put_update_push_token.go @@ -0,0 +1,44 @@ +package push + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/null/v8" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func PutUpdatePushTokenRoute(s *api.Server) *echo.Route { + return s.Router.APIV1Push.PUT("/token", putUpdatePushTokenHandler(s)) +} + +func putUpdatePushTokenHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + user := auth.UserFromEchoContext(c) + log := util.LogFromContext(ctx) + + var body types.PutUpdatePushTokenPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + return err + } + + err := s.Local.UpdatePushToken(ctx, dto.UpdatePushTokenRequest{ + User: *user, + Token: swag.StringValue(body.NewToken), + Provider: swag.StringValue(body.Provider), + ExistingToken: null.StringFromPtr(body.OldToken), + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to update push token") + return err + } + + return c.String(http.StatusOK, "Success") + } +} diff --git a/internal/api/handlers/push/put_update_push_token_test.go b/internal/api/handlers/push/put_update_push_token_test.go new file mode 100644 index 0000000..934f011 --- /dev/null +++ b/internal/api/handlers/push/put_update_push_token_test.go @@ -0,0 +1,169 @@ +package push_test + +import ( + "database/sql" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPutUpdatePushTokenSuccess(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + //nolint:gosec + testToken := "869f6deb-73e6-4691-9d40-2a2a794006cf" + testProvider := models.ProviderTypeFCM + + payload := test.GenericPayload{ + "newToken": testToken, + "provider": testProvider, + } + + res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB) + require.NoError(t, err) + assert.NotEmpty(t, newToken.ID) + assert.Equal(t, testToken, newToken.Token) + assert.Equal(t, testProvider, newToken.Provider) + assert.Equal(t, fix.User1.ID, newToken.UserID) + }) +} + +func TestPutUpdatePushTokenSuccessWithOldToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + //nolint:gosec + oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd" + + oldPushToken := models.PushToken{ + Token: oldToken, + Provider: models.ProviderTypeFCM, + UserID: fix.User1.ID, + } + err := oldPushToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + //nolint:gosec + testToken := "af55b6cf-1fb0-4bb7-960c-25268a5ce7c3" + testProvider := models.ProviderTypeFCM + + payload := test.GenericPayload{ + "newToken": testToken, + "provider": testProvider, + "oldToken": oldToken, + } + + res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + assert.Equal(t, http.StatusOK, res.Result().StatusCode) + + newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB) + require.NoError(t, err) + assert.NotEmpty(t, newToken.ID) + assert.Equal(t, testToken, newToken.Token) + assert.Equal(t, testProvider, newToken.Provider) + assert.Equal(t, fix.User1.ID, newToken.UserID) + + err = oldPushToken.Reload(ctx, s.DB) + require.ErrorIs(t, err, sql.ErrNoRows) + }) +} + +func TestPutUpdatePushTokenWithDuplicateToken(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + //nolint:gosec + oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd" + + oldPushToken := models.PushToken{ + Token: oldToken, + Provider: models.ProviderTypeFCM, + UserID: fix.User1.ID, + } + err := oldPushToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + testProvider := models.ProviderTypeFCM + payload := test.GenericPayload{ + "newToken": oldToken, + "provider": testProvider, + "oldToken": oldToken, + } + + oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB) + require.NoError(t, err) + + res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.ErrConflictPushToken) + + err = oldPushToken.Reload(ctx, s.DB) + require.NoError(t, err) + + cnt, err := fix.User1.PushTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, oldCnt, cnt) + }) +} + +func TestPutUpdatePushTokenWithOldTokenNotfound(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + fix := fixtures.Fixtures() + + //nolint:gosec + oldToken := "cc08624a-b40d-4b8e-bbfe-f62aabb47592" + + oldPushToken := models.PushToken{ + Token: oldToken, + Provider: models.ProviderTypeFCM, + UserID: fix.User1.ID, + } + err := oldPushToken.Insert(ctx, s.DB, boil.Infer()) + require.NoError(t, err) + + oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB) + require.NoError(t, err) + + //nolint:gosec + testToken := "8e4ad85f-cbb6-4ef3-a455-d9d8bd8917b3" + testProvider := models.ProviderTypeFCM + + payload := test.GenericPayload{ + "newToken": testToken, + "provider": testProvider, + "oldToken": "3199aa21-eb41-47dd-9287-338e9e88a5ae", + } + + res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + test.RequireHTTPError(t, res, httperrors.ErrNotFoundOldPushToken) + + newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB) + require.NoError(t, err) + assert.NotEmpty(t, newToken.ID) + assert.Equal(t, testToken, newToken.Token) + assert.Equal(t, testProvider, newToken.Provider) + assert.Equal(t, fix.User1.ID, newToken.UserID) + + err = oldPushToken.Reload(ctx, s.DB) + require.NoError(t, err) + + cnt, err := fix.User1.PushTokens().Count(ctx, s.DB) + require.NoError(t, err) + assert.Equal(t, oldCnt+1, cnt) + }) +} diff --git a/internal/api/handlers/wellknown/get_android_assetlinks.go b/internal/api/handlers/wellknown/get_android_assetlinks.go new file mode 100644 index 0000000..304660c --- /dev/null +++ b/internal/api/handlers/wellknown/get_android_assetlinks.go @@ -0,0 +1,23 @@ +package wellknown + +import ( + "allaboutapps.dev/aw/go-starter/internal/api" + "github.com/labstack/echo/v4" +) + +func GetAndroidDigitalAssetLinksRoute(s *api.Server) *echo.Route { + return s.Router.WellKnown.GET("/assetlinks.json", getAndroidDigitalAssetLinksHandler(s)) +} + +func getAndroidDigitalAssetLinksHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + if s.Config.Paths.AndroidAssetlinksFile == "" { + return echo.ErrNotFound + } + + c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate") + c.Response().Header().Set("Content-Type", "application/json") + + return c.File(s.Config.Paths.AndroidAssetlinksFile) + } +} diff --git a/internal/api/handlers/wellknown/get_android_assetlinks_test.go b/internal/api/handlers/wellknown/get_android_assetlinks_test.go new file mode 100644 index 0000000..2f74ae1 --- /dev/null +++ b/internal/api/handlers/wellknown/get_android_assetlinks_test.go @@ -0,0 +1,30 @@ +package wellknown_test + +import ( + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" +) + +func TestGetAndroidWellKnown(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Paths.AndroidAssetlinksFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "android-assetlinks.json") + + testGetWellKnown(t, config, "/.well-known/assetlinks.json") +} + +func TestGetAndroidWellKnownNotFound(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Paths.AndroidAssetlinksFile = "" + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/.well-known/assetlinks.json", nil, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound)) + }) +} diff --git a/internal/api/handlers/wellknown/get_apple_app_site_association.go b/internal/api/handlers/wellknown/get_apple_app_site_association.go new file mode 100644 index 0000000..8b11d32 --- /dev/null +++ b/internal/api/handlers/wellknown/get_apple_app_site_association.go @@ -0,0 +1,21 @@ +package wellknown + +import ( + "allaboutapps.dev/aw/go-starter/internal/api" + "github.com/labstack/echo/v4" +) + +func GetAppleAppSiteAssociationRoute(s *api.Server) *echo.Route { + return s.Router.WellKnown.GET("/apple-app-site-association", getAppleAppSiteAssociationHandler(s)) +} + +func getAppleAppSiteAssociationHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + if s.Config.Paths.AppleAppSiteAssociationFile == "" { + return echo.ErrNotFound + } + + c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate") + return c.File(s.Config.Paths.AppleAppSiteAssociationFile) + } +} diff --git a/internal/api/handlers/wellknown/get_apple_app_site_association_test.go b/internal/api/handlers/wellknown/get_apple_app_site_association_test.go new file mode 100644 index 0000000..c58db1f --- /dev/null +++ b/internal/api/handlers/wellknown/get_apple_app_site_association_test.go @@ -0,0 +1,47 @@ +package wellknown_test + +import ( + "io" + "net/http" + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func testGetWellKnown(t *testing.T, config config.Server, path string) { + t.Helper() + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", path, nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + result, err := io.ReadAll(res.Body) + require.NoError(t, err) + + test.Snapshoter.SaveString(t, string(result)) + }) +} + +func TestGetAppleWellKnown(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Paths.AppleAppSiteAssociationFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "apple-app-site-association.json") + + testGetWellKnown(t, config, "/.well-known/apple-app-site-association") +} + +func TestGetAppleWellKnownNotFound(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Paths.AppleAppSiteAssociationFile = "" + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/.well-known/apple-app-site-association", nil, nil) + test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound)) + }) +} diff --git a/internal/api/httperrors/auth.go b/internal/api/httperrors/auth.go new file mode 100644 index 0000000..01b0ebd --- /dev/null +++ b/internal/api/httperrors/auth.go @@ -0,0 +1,16 @@ +package httperrors + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +var ( + ErrForbiddenUserDeactivated = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeUSERDEACTIVATED, "User account is deactivated") + ErrBadRequestInvalidPassword = NewHTTPErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeINVALIDPASSWORD, "The password provided was invalid", "Password was either too weak or did not match other criteria") + ErrForbiddenNotLocalUser = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeNOTLOCALUSER, "User account is not valid for local authentication") + ErrNotFoundTokenNotFound = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeTOKENNOTFOUND, "Provided token was not found") + ErrConflictTokenExpired = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeTOKENEXPIRED, "Provided token has expired and is no longer valid") + ErrConflictUserAlreadyExists = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeUSERALREADYEXISTS, "User with given username already exists") +) diff --git a/internal/api/httperrors/common.go b/internal/api/httperrors/common.go new file mode 100644 index 0000000..72ec7a7 --- /dev/null +++ b/internal/api/httperrors/common.go @@ -0,0 +1,11 @@ +package httperrors + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +var ( + ErrBadRequestZeroFileSize = NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeZEROFILESIZE, "File size of 0 is not supported.") +) diff --git a/internal/api/httperrors/error.go b/internal/api/httperrors/error.go new file mode 100644 index 0000000..397ca48 --- /dev/null +++ b/internal/api/httperrors/error.go @@ -0,0 +1,147 @@ +package httperrors + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +// Payload in accordance with RFC 7807 (Problem Details for HTTP APIs) with the exception of the type +// value not being represented by a URI. https://tools.ietf.org/html/rfc7807 @ 2020-04-27T15:44:37Z + +type HTTPError struct { + types.PublicHTTPError + Internal error `json:"-"` + AdditionalData map[string]interface{} `json:"-"` +} + +type HTTPValidationError struct { + types.PublicHTTPValidationError + Internal error `json:"-"` + AdditionalData map[string]interface{} `json:"-"` +} + +func NewHTTPError(code int, errorType types.PublicHTTPErrorType, title string) *HTTPError { + return &HTTPError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(code)), + Type: errorType.Pointer(), + Title: swag.String(title), + }, + } +} + +func NewHTTPErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, detail string) *HTTPError { + return &HTTPError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(code)), + Type: errorType.Pointer(), + Title: swag.String(title), + Detail: detail, + }, + } +} + +func NewFromEcho(e *echo.HTTPError) *HTTPError { + return NewHTTPError(e.Code, types.PublicHTTPErrorTypeGeneric, http.StatusText(e.Code)) +} + +func (e *HTTPError) Error() string { + var builder strings.Builder + + fmt.Fprintf(&builder, "HTTPError %d (%s): %s", *e.Code, *e.Type, *e.Title) + + if len(e.Detail) > 0 { + fmt.Fprintf(&builder, " - %s", e.Detail) + } + if e.Internal != nil { + fmt.Fprintf(&builder, ", %v", e.Internal) + } + if len(e.AdditionalData) > 0 { + keys := make([]string, 0, len(e.AdditionalData)) + for k := range e.AdditionalData { + keys = append(keys, k) + } + sort.Strings(keys) + + builder.WriteString(". Additional: ") + for i, k := range keys { + fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k]) + if i < len(keys)-1 { + builder.WriteString(", ") + } + } + } + + return builder.String() +} + +func NewHTTPValidationError(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail) *HTTPValidationError { + return &HTTPValidationError{ + PublicHTTPValidationError: types.PublicHTTPValidationError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(code)), + Type: errorType.Pointer(), + Title: swag.String(title), + }, + ValidationErrors: validationErrors, + }, + } +} + +func NewHTTPValidationErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail, detail string) *HTTPValidationError { + return &HTTPValidationError{ + PublicHTTPValidationError: types.PublicHTTPValidationError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(code)), + Type: errorType.Pointer(), + Title: swag.String(title), + Detail: detail, + }, + ValidationErrors: validationErrors, + }, + } +} + +func (e *HTTPValidationError) Error() string { + var builder strings.Builder + + fmt.Fprintf(&builder, "HTTPValidationError %d (%s): %s", *e.Code, *e.Type, *e.Title) + + if len(e.Detail) > 0 { + fmt.Fprintf(&builder, " - %s", e.Detail) + } + if e.Internal != nil { + fmt.Fprintf(&builder, ", %v", e.Internal) + } + if len(e.AdditionalData) > 0 { + keys := make([]string, 0, len(e.AdditionalData)) + for k := range e.AdditionalData { + keys = append(keys, k) + } + sort.Strings(keys) + + builder.WriteString(". Additional: ") + for i, k := range keys { + fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k]) + if i < len(keys)-1 { + builder.WriteString(", ") + } + } + } + + builder.WriteString(" - Validation: ") + for i, ve := range e.ValidationErrors { + fmt.Fprintf(&builder, "%s (in %s): %s", *ve.Key, *ve.In, *ve.Error) + if i < len(e.ValidationErrors)-1 { + builder.WriteString(", ") + } + } + + return builder.String() +} diff --git a/internal/api/httperrors/error_test.go b/internal/api/httperrors/error_test.go new file mode 100644 index 0000000..ee9cf55 --- /dev/null +++ b/internal/api/httperrors/error_test.go @@ -0,0 +1,80 @@ +package httperrors_test + +import ( + "database/sql" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/go-openapi/swag" + "github.com/stretchr/testify/require" +) + +func TestHTTPErrorSimple(t *testing.T) { + err := httperrors.NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound)) + require.Equal(t, "HTTPError 404 (generic): Not Found", err.Error()) +} + +func TestHTTPErrorDetail(t *testing.T) { + err := httperrors.NewHTTPErrorWithDetail(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound), "ToS violation") + require.Equal(t, "HTTPError 404 (generic): Not Found - ToS violation", err.Error()) +} + +func TestHTTPErrorInternalError(t *testing.T) { + err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError)) + + err.Internal = sql.ErrConnDone + + require.Equal(t, "HTTPError 500 (generic): Internal Server Error, sql: connection is already closed", err.Error()) +} + +func TestHTTPErrorAdditionalData(t *testing.T) { + err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError)) + + err.AdditionalData = map[string]interface{}{ + "key1": "value1", + "key2": "value2", + } + + require.Equal(t, "HTTPError 500 (generic): Internal Server Error. Additional: key1=value1, key2=value2", err.Error()) +} + +var valErrs = append(make([]*types.HTTPValidationErrorDetail, 0, 2), &types.HTTPValidationErrorDetail{ + Key: swag.String("test1"), + In: swag.String("body.test1"), + Error: swag.String("ValidationError"), +}, &types.HTTPValidationErrorDetail{ + Key: swag.String("test2"), + In: swag.String("body.test2"), + Error: swag.String("Validation Error"), +}) + +func TestHTTPValidationErrorSimple(t *testing.T) { + err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs) + require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error()) +} + +func TestHTTPValidationErrorDetail(t *testing.T) { + err := httperrors.NewHTTPValidationErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs, "Did API spec change?") + require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Did API spec change? - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error()) +} + +func TestHTTPValidationErrorInternalError(t *testing.T) { + err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs) + + err.Internal = sql.ErrConnDone + + require.Equal(t, "HTTPValidationError 400 (generic): Bad Request, sql: connection is already closed - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error()) +} + +func TestHTTPValidationErrorAdditionalData(t *testing.T) { + err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs) + + err.AdditionalData = map[string]interface{}{ + "key1": "value1", + "key2": "value2", + } + + require.Equal(t, "HTTPValidationError 400 (generic): Bad Request. Additional: key1=value1, key2=value2 - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error()) +} diff --git a/internal/api/httperrors/push.go b/internal/api/httperrors/push.go new file mode 100644 index 0000000..3574cc5 --- /dev/null +++ b/internal/api/httperrors/push.go @@ -0,0 +1,12 @@ +package httperrors + +import ( + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +var ( + ErrConflictPushToken = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypePUSHTOKENALREADYEXISTS, "The given token already exists.") + ErrNotFoundOldPushToken = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeOLDPUSHTOKENNOTFOUND, "The old push token does not exists. The new token was saved.") +) diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..e275f91 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,390 @@ +package middleware + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/data/mapper" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/go-openapi/strfmt" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog/log" +) + +var ( + ErrBadRequestMalformedToken = httperrors.NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeMALFORMEDTOKEN, "Auth token is malformed") + ErrUnauthorizedLastAuthenticatedAtExceeded = httperrors.NewHTTPError(http.StatusUnauthorized, types.PublicHTTPErrorTypeLASTAUTHENTICATEDATEXCEEDED, "LastAuthenticatedAt timestamp exceeds threshold, re-authentication required") + ErrForbiddenMissingScopes = httperrors.NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeMISSINGSCOPES, "User is missing required scopes") + ErrAuthTokenValidationFailed = errors.New("auth token validation failed") +) + +// AuthMode controls the type of authentication check performed for a specific route or group +type AuthMode int + +const ( + // AuthModeRequired requires an auth token to be present and valid in order to access the route or group + AuthModeRequired AuthMode = iota + // AuthModeSecure requires an auth token to be present and for the user to have recently re-confirmed their authentication in order to access the route or group + AuthModeSecure + // AuthModeOptional does not require an auth token to be present, however if it is, it must be valid in order to access the route or group + AuthModeOptional + // AuthModeTry does not require an auth token to be present in order to access the route or group and will process the request even if an invalid one has been provided + AuthModeTry + // AuthModeNone does not require an auth token to be present in order to access the route or group and will not attempt to parse any authentication provided + AuthModeNone +) + +func (m AuthMode) String() string { + switch m { + case AuthModeRequired: + return "required" + case AuthModeSecure: + return "secure" + case AuthModeOptional: + return "optional" + case AuthModeTry: + return "try" + case AuthModeNone: + return "none" + default: + return fmt.Sprintf("unknown (%d)", m) + } +} + +type AuthFailureMode int + +const ( + // AuthFailureModeUnauthorized returns a 401 Unauthorized response on missing or invalid authentication + AuthFailureModeUnauthorized AuthFailureMode = iota + // AuthFailureModeNotFound returns a 404 Not Found response on missing or invalid authentication + AuthFailureModeNotFound +) + +func (m AuthFailureMode) String() string { + switch m { + case AuthFailureModeUnauthorized: + return "unauthorized" + case AuthFailureModeNotFound: + return "not_found" + default: + return fmt.Sprintf("unknown (%d)", m) + } +} + +func (m AuthFailureMode) Error() error { + switch m { + case AuthFailureModeUnauthorized: + return echo.ErrUnauthorized + case AuthFailureModeNotFound: + return echo.ErrNotFound + default: + return echo.ErrInternalServerError + } +} + +type AuthTokenSource int + +const ( + // AuthTokenSourceHeader retrieves the auth token from a header, specified by TokenSourceKey + AuthTokenSourceHeader AuthTokenSource = iota + // AuthTokenSourceQuery retrieves the auth token from a query parameter, specified by TokenSourceKey + AuthTokenSourceQuery + // AuthTokenSourceForm retrieves the auth token from a form parameter, specified by TokenSourceKey + AuthTokenSourceForm +) + +func (s AuthTokenSource) String() string { + switch s { + case AuthTokenSourceHeader: + return "header" + case AuthTokenSourceQuery: + return "query" + case AuthTokenSourceForm: + return "form" + default: + return fmt.Sprintf("unknown (%d)", s) + } +} + +func (s AuthTokenSource) Extract(c echo.Context, key string, scheme string) (string, bool) { + var token string + + switch s { + case AuthTokenSourceHeader: + token = c.Request().Header.Get(key) + case AuthTokenSourceForm: + token = c.FormValue(key) + case AuthTokenSourceQuery: + token = c.QueryParam(key) + default: + return "", false + } + + if len(token) == 0 { + return "", false + } + + lenScheme := len(scheme) + if lenScheme == 0 { + return token, true + } + + if len(token) < lenScheme+1 { + return "", true + } + + if token[:lenScheme] != scheme { + return "", true + } + + return token[lenScheme+1:], true +} + +type AuthTokenFormatValidator func(string) bool + +func DefaultAuthTokenFormatValidator(token string) bool { + return strfmt.IsUUID4(token) +} + +type AuthTokenValidator func(c echo.Context, config AuthConfig, token string) (auth.Result, error) + +func DefaultAuthTokenValidator(c echo.Context, config AuthConfig, token string) (auth.Result, error) { + accessToken, err := models.AccessTokens( + models.AccessTokenWhere.Token.EQ(token), + qm.Load(qm.Rels(models.AccessTokenRels.User, models.UserRels.AppUserProfile)), + ).One(c.Request().Context(), config.S.DB) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Trace().Err(err).Msg("Access token not found in database") + return auth.Result{}, ErrAuthTokenValidationFailed + } + + log.Error().Err(err).Msg("Failed to query for access token in database, aborting request") + return auth.Result{}, echo.ErrInternalServerError + } + + return auth.Result{ + Token: accessToken.Token, + User: mapper.LocalUserToDTO(accessToken.R.User).Ptr(), + ValidUntil: accessToken.ValidUntil, + }, nil +} + +var ( + DefaultAuthConfig = AuthConfig{ + Mode: AuthModeRequired, + FailureMode: AuthFailureModeUnauthorized, + TokenSource: AuthTokenSourceHeader, + TokenSourceKey: echo.HeaderAuthorization, + Scheme: "Bearer", + Skipper: middleware.DefaultSkipper, + FormatValidator: DefaultAuthTokenFormatValidator, + TokenValidator: DefaultAuthTokenValidator, + Scopes: []string{auth.ScopeApp.String()}, + } +) + +type AuthConfig struct { + S *api.Server // API server used for database and service access + Mode AuthMode // Controls type of authentication required (default: AuthModeRequired) + FailureMode AuthFailureMode // Controls response on auth failure (default: AuthFailureModeUnauthorized) + TokenSource AuthTokenSource // Sets source of auth token (default: AuthTokenSourceHeader) + TokenSourceKey string // Sets key for auth token source lookup (default: "Authorization") + Scheme string // Sets required token scheme (default: "Bearer") + Skipper middleware.Skipper // Controls skipping of certain routes (default: no skipped routes) + FormatValidator AuthTokenFormatValidator // Validates the format of the token retrieved + TokenValidator AuthTokenValidator // Validates token retrieved and returns associated user (default: performs lookup in access_tokens table) + Scopes []string // List of scopes required to access endpoint (default: none required) +} + +func (c AuthConfig) CheckLastAuthenticatedAt(user *dto.User) bool { + if c.Mode != AuthModeSecure { + return true + } + + if !user.LastAuthenticatedAt.Valid { + return false + } + + return time.Since(user.LastAuthenticatedAt.Time).Seconds() <= c.S.Config.Auth.LastAuthenticatedAtThreshold.Seconds() +} + +func (c AuthConfig) CheckUserScopes(user *dto.User) bool { + if len(c.Scopes) == 0 { + return true + } + + if len(user.Scopes) == 0 { + return false + } + + for _, scope := range c.Scopes { + for _, userScope := range user.Scopes { + if scope == userScope { + return true + } + } + } + + return false +} + +func Auth(s *api.Server) echo.MiddlewareFunc { + c := DefaultAuthConfig + c.S = s + return AuthWithConfig(c) +} + +func AuthWithConfig(config AuthConfig) echo.MiddlewareFunc { + if config.S == nil { + panic("auth middleware: server is required") + } + + if len(config.TokenSourceKey) == 0 { + config.TokenSourceKey = DefaultAuthConfig.TokenSourceKey + } + + if len(config.Scheme) == 0 { + config.Scheme = DefaultAuthConfig.Scheme + } + + if config.Skipper == nil { + config.Skipper = DefaultAuthConfig.Skipper + } + + if config.FormatValidator == nil { + config.FormatValidator = DefaultAuthConfig.FormatValidator + } + + if config.TokenValidator == nil { + config.TokenValidator = DefaultAuthConfig.TokenValidator + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log := util.LogFromEchoContext(c).With().Str("middleware", "auth").Str("auth_mode", config.Mode.String()).Logger() + + if config.Mode == AuthModeNone { + log.Trace().Msg("No authentication required, allowing request") + return next(c) + } + + if config.Skipper(c) { + log.Trace().Msg("Skipping auth middleware, allowing request") + return next(c) + } + + user := auth.UserFromEchoContext(c) + if user != nil { + if !config.CheckLastAuthenticatedAt(user) { + log.Trace(). + Time("last_authenticated_at", user.LastAuthenticatedAt.Time). + Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold). + Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request") + return ErrUnauthorizedLastAuthenticatedAtExceeded + } + + if !config.CheckUserScopes(user) { + log.Trace(). + Strs("scopes", config.Scopes). + Strs("user_scopes", user.Scopes). + Msg("Authentication already performed, but user does not have required scopes, rejecting request") + return ErrForbiddenMissingScopes + } + + log.Trace().Msg("Authentication already performed, allowing request") + return next(c) + } + + token, exists := config.TokenSource.Extract(c, config.TokenSourceKey, config.Scheme) + if len(token) == 0 { + if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || (exists && config.Mode == AuthModeOptional) { + log.Trace().Bool("token_exists", exists).Msg("Request has missing or malformed token, rejecting") + return config.FailureMode.Error() + } + + log.Trace().Bool("token_exists", exists).Msg("Request does not have valid token, but auth mode permits access, allowing request") + return next(c) + } + + if !config.FormatValidator(token) { + if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || config.Mode == AuthModeOptional { + log.Trace().Msg("Request has malformed token, rejecting") + return ErrBadRequestMalformedToken + } + + log.Trace().Msg("Request have malformed token, but auth mode permits access, allowing request") + return next(c) + } + + res, err := config.TokenValidator(c, config, token) + if err != nil { + if errors.Is(err, ErrAuthTokenValidationFailed) { + if config.Mode == AuthModeTry { + log.Trace().Msg("Auth token validation failed, but auth mode permits access, allowing request") + return next(c) + } + + log.Trace().Msg("Auth token validation failed, rejecting request") + return config.FailureMode.Error() + } + + log.Trace().Err(err).Msg("Failed to validate auth token, aborting request") + return echo.ErrInternalServerError + } + + user = res.User + + if res.ValidUntil.IsZero() { + log.Trace().Str("user_id", user.ID).Msg("Auth token has no expiry, allowing request") + } else if config.S.Clock.Now().After(res.ValidUntil) { + if config.Mode == AuthModeTry { + log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, but auth mode permits access, allowing request") + return next(c) + } + + log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, rejecting request") + return config.FailureMode.Error() + } + + // ! User has been explicitly deactivated - we do not allow access here, even with AuthModeTry + if !user.IsActive { + log.Trace().Str("user_id", user.ID).Msg("User is deactivated, rejecting request") + return httperrors.ErrForbiddenUserDeactivated + } + + if !config.CheckLastAuthenticatedAt(user) { + log.Trace(). + Time("last_authenticated_at", user.LastAuthenticatedAt.Time). + Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold). + Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request") + return ErrUnauthorizedLastAuthenticatedAtExceeded + } + + if !config.CheckUserScopes(user) { + log.Trace(). + Strs("scopes", config.Scopes). + Strs("user_scopes", user.Scopes). + Msg("Authentication already performed, but user does not have required scopes, rejecting request") + return ErrForbiddenMissingScopes + } + + auth.EnrichEchoContextWithCredentials(c, res) + + log.Trace().Str("user_id", user.ID).Msg("Auth token is valid, allowing request") + + return next(c) + } + } +} diff --git a/internal/api/middleware/cache_control.go b/internal/api/middleware/cache_control.go new file mode 100644 index 0000000..e949f68 --- /dev/null +++ b/internal/api/middleware/cache_control.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "context" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +var ( + DefaultCacheControlConfig = CacheControlConfig{ + Skipper: middleware.DefaultSkipper, + } +) + +type CacheControlConfig struct { + Skipper middleware.Skipper +} + +func CacheControl() echo.MiddlewareFunc { + return CacheControlWithConfig(DefaultCacheControlConfig) +} + +func CacheControlWithConfig(config CacheControlConfig) echo.MiddlewareFunc { + if config.Skipper == nil { + config.Skipper = DefaultCacheControlConfig.Skipper + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if config.Skipper(c) { + return next(c) + } + + cacheControl := c.Request().Header.Get(util.HTTPHeaderCacheControl) + if len(cacheControl) > 0 { + directive := util.ParseCacheControlHeader(cacheControl) + + ctx := c.Request().Context() + + l := util.LogFromContext(ctx).With().Str("cacheControl", directive.String()).Logger() + ctx = l.WithContext(ctx) + + ctx = context.WithValue(ctx, util.CTXKeyCacheControl, directive) + + l.Trace().Msg("Setting cache control directive for request") + + c.SetRequest(c.Request().WithContext(ctx)) + } + + return next(c) + } + } +} diff --git a/internal/api/middleware/logger.go b/internal/api/middleware/logger.go new file mode 100644 index 0000000..1d17776 --- /dev/null +++ b/internal/api/middleware/logger.go @@ -0,0 +1,334 @@ +package middleware + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// RequestBodyLogSkipper defines a function to skip logging certain request bodies. +// Returning true skips logging the payload of the request. +type RequestBodyLogSkipper func(req *http.Request) bool + +// DefaultRequestBodyLogSkipper returns true for all requests with Content-Type +// application/x-www-form-urlencoded or multipart/form-data as those might contain +// binary or URL-encoded file uploads unfit for logging purposes. +func DefaultRequestBodyLogSkipper(req *http.Request) bool { + contentType := req.Header.Get(echo.HeaderContentType) + switch { + case strings.HasPrefix(contentType, echo.MIMEApplicationForm), + strings.HasPrefix(contentType, echo.MIMEMultipartForm): + return true + default: + return false + } +} + +// ResponseBodyLogSkipper defines a function to skip logging certain response bodies. +// Returning true skips logging the payload of the response. +type ResponseBodyLogSkipper func(req *http.Request, res *echo.Response) bool + +// DefaultResponseBodyLogSkipper returns false for all responses with Content-Type +// application/json, preventing logging for all other types of payloads as those +// might contain binary or URL-encoded data unfit for logging purposes. +func DefaultResponseBodyLogSkipper(_ *http.Request, res *echo.Response) bool { + contentType := res.Header().Get(echo.HeaderContentType) + switch { + case strings.HasPrefix(contentType, echo.MIMEApplicationJSON): + return false + default: + return true + } +} + +// BodyLogReplacer defines a function to replace certain parts of a body before logging it, +// mainly used to strip sensitive information from a request or response payload. +// The []byte returned should contain a sanitized payload ready for logging. +type BodyLogReplacer func(body []byte) []byte + +// DefaultBodyLogReplacer returns the body received without any modifications. +func DefaultBodyLogReplacer(body []byte) []byte { + return body +} + +// HeaderLogReplacer defines a function to replace certain parts of a header before logging it, +// mainly used to strip sensitive information from a request or response header. +// The http.Header returned should be a sanitized copy of the original header as not to modify +// the request or response while logging. +type HeaderLogReplacer func(header http.Header) http.Header + +// DefaultHeaderLogReplacer replaces all Authorization, X-CSRF-Token and Proxy-Authorization +// header entries with a redacted string, indicating their presence without revealing actual, +// potentially sensitive values in the logs. +func DefaultHeaderLogReplacer(headers http.Header) http.Header { + sanitizedHeader := http.Header{} + + for key, value := range headers { + shouldRedact := strings.EqualFold(key, echo.HeaderAuthorization) || + strings.EqualFold(key, echo.HeaderXCSRFToken) || + strings.EqualFold(key, "Proxy-Authorization") + + for _, v := range value { + if shouldRedact { + sanitizedHeader.Add(key, "*****REDACTED*****") + } else { + sanitizedHeader.Add(key, v) + } + } + } + + return sanitizedHeader +} + +// QueryLogReplacer defines a function to replace certain parts of a URL query before logging it, +// mainly used to strip sensitive information from a request query. +// The url.Values returned should be a sanitized copy of the original query as not to modify the +// request while logging. +type QueryLogReplacer func(query url.Values) url.Values + +// DefaultQueryLogReplacer returns the query received without any modifications. +func DefaultQueryLogReplacer(query url.Values) url.Values { + return query +} + +var ( + DefaultLoggerConfig = LoggerConfig{ + Skipper: middleware.DefaultSkipper, + Level: zerolog.DebugLevel, + LogRequestBody: false, + LogRequestHeader: false, + LogRequestQuery: false, + RequestBodyLogSkipper: DefaultRequestBodyLogSkipper, + RequestBodyLogReplacer: DefaultBodyLogReplacer, + RequestHeaderLogReplacer: DefaultHeaderLogReplacer, + RequestQueryLogReplacer: DefaultQueryLogReplacer, + LogResponseBody: false, + LogResponseHeader: false, + ResponseBodyLogSkipper: DefaultResponseBodyLogSkipper, + ResponseBodyLogReplacer: DefaultBodyLogReplacer, + } +) + +type LoggerConfig struct { + Skipper middleware.Skipper + Level zerolog.Level + LogRequestBody bool + LogRequestHeader bool + LogRequestQuery bool + LogCaller bool + RequestBodyLogSkipper RequestBodyLogSkipper + RequestBodyLogReplacer BodyLogReplacer + RequestHeaderLogReplacer HeaderLogReplacer + RequestQueryLogReplacer QueryLogReplacer + LogResponseBody bool + LogResponseHeader bool + ResponseBodyLogSkipper ResponseBodyLogSkipper + ResponseBodyLogReplacer BodyLogReplacer + ResponseHeaderLogReplacer HeaderLogReplacer +} + +// Logger with default logger output and configuration +func Logger() echo.MiddlewareFunc { + return LoggerWithConfig(DefaultLoggerConfig, nil) +} + +// LoggerWithConfig returns a new MiddlewareFunc which creates a logger with the desired configuration. +// If output is set to nil, the default output is used. If more output params are provided, the first is being used. +func LoggerWithConfig(config LoggerConfig, output ...io.Writer) echo.MiddlewareFunc { + if config.Skipper == nil { + config.Skipper = DefaultLoggerConfig.Skipper + } + if config.RequestBodyLogSkipper == nil { + config.RequestBodyLogSkipper = DefaultRequestBodyLogSkipper + } + if config.RequestBodyLogReplacer == nil { + config.RequestBodyLogReplacer = DefaultBodyLogReplacer + } + if config.RequestHeaderLogReplacer == nil { + config.RequestHeaderLogReplacer = DefaultHeaderLogReplacer + } + if config.RequestQueryLogReplacer == nil { + config.RequestQueryLogReplacer = DefaultQueryLogReplacer + } + if config.ResponseBodyLogSkipper == nil { + config.ResponseBodyLogSkipper = DefaultResponseBodyLogSkipper + } + if config.ResponseBodyLogReplacer == nil { + config.ResponseBodyLogReplacer = DefaultBodyLogReplacer + } + if config.ResponseHeaderLogReplacer == nil { + config.ResponseHeaderLogReplacer = DefaultHeaderLogReplacer + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if config.Skipper(c) { + return next(c) + } + + req := c.Request() + res := c.Response() + + requestID := req.Header.Get(echo.HeaderXRequestID) + if len(requestID) == 0 { + requestID = res.Header().Get(echo.HeaderXRequestID) + } + + contentLength := req.Header.Get(echo.HeaderContentLength) + if len(contentLength) == 0 { + contentLength = "0" + } + + logger := log.With(). + Dict("req", zerolog.Dict(). + Str("id", requestID). + Str("host", req.Host). + Str("method", req.Method). + Str("url", req.URL.String()). + Str("bytes_in", contentLength), + ).Logger() + + if len(output) > 0 { + logger = logger.Output(output[0]) + } + + if config.LogCaller { + // Caller uses https://pkg.go.dev/runtime#Caller underneath and might decrease the performance. + logger = logger.With().Caller().Logger() + } + + le := logger.WithLevel(config.Level) + req = req.WithContext(logger.WithContext(context.WithValue(req.Context(), util.CTXKeyRequestID, requestID))) + + if config.LogRequestBody && !config.RequestBodyLogSkipper(req) { + var reqBody []byte + var err error + if req.Body != nil { + reqBody, err = io.ReadAll(req.Body) + if err != nil { + logger.Error().Err(err).Msg("Failed to read body while logging request") + return fmt.Errorf("failed to read body while logging request: %w", err) + } + + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + } + + le = le.Bytes("req_body", config.RequestBodyLogReplacer(reqBody)) + } + if config.LogRequestHeader { + header := zerolog.Dict() + for k, v := range config.RequestHeaderLogReplacer(req.Header) { + header.Strs(k, v) + } + + le = le.Dict("req_header", header) + } + if config.LogRequestQuery { + query := zerolog.Dict() + for k, v := range req.URL.Query() { + query.Strs(k, v) + } + + le = le.Dict("req_query", query) + } + + le.Msg("Request received") + + c.SetRequest(req) + + var resBody bytes.Buffer + if config.LogResponseBody { + mw := io.MultiWriter(res.Writer, &resBody) + writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: res.Writer} + res.Writer = writer + } + + start := time.Now() + err := next(c) + if err != nil { + c.Error(err) + } + stop := time.Now() + + // Retrieve logger from context again since other middlewares might have enhanced it + ll := util.LogFromEchoContext(c) + lle := ll.WithLevel(config.Level). + Dict("res", zerolog.Dict(). + Int("status", res.Status). + Int64("bytes_out", res.Size). + TimeDiff("duration_ms", stop, start). + Err(err), + ) + + if config.LogResponseBody && !config.ResponseBodyLogSkipper(req, res) { + lle = lle.Bytes("res_body", config.ResponseBodyLogReplacer(resBody.Bytes())) + } + if config.LogResponseHeader { + header := zerolog.Dict() + for k, v := range config.ResponseHeaderLogReplacer(res.Header()) { + header.Strs(k, v) + } + + lle = lle.Dict("res_header", header) + } + + lle.Msg("Response sent") + + return nil + } + } +} + +type bodyDumpResponseWriter struct { + io.Writer + http.ResponseWriter +} + +func (w *bodyDumpResponseWriter) WriteHeader(code int) { + w.ResponseWriter.WriteHeader(code) +} + +func (w *bodyDumpResponseWriter) Write(b []byte) (int, error) { + n, err := w.Writer.Write(b) + if err != nil { + return 0, fmt.Errorf("failed to write response body: %w", err) + } + + return n, nil +} + +func (w *bodyDumpResponseWriter) Flush() { + flusher, ok := w.ResponseWriter.(http.Flusher) + if !ok { + panic(fmt.Sprintf("failed to get flusher as http.Flusher, got %T", w.ResponseWriter)) + } + + flusher.Flush() +} + +func (w *bodyDumpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("failed to get hijacker as http.Hijacker, got %T", w.ResponseWriter) + } + + conn, rw, err := hijacker.Hijack() + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack connection: %w", err) + } + + return conn, rw, nil +} diff --git a/internal/api/middleware/logger_test.go b/internal/api/middleware/logger_test.go new file mode 100644 index 0000000..b4a9fdc --- /dev/null +++ b/internal/api/middleware/logger_test.go @@ -0,0 +1,57 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api/middleware" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func logTestHandler(c echo.Context) error { + log := util.LogFromEchoContext(c) + log.Info().Msg("I'm here!") + return nil +} + +func TestLogWithCaller(t *testing.T) { + cfg := middleware.DefaultLoggerConfig + cfg.LogCaller = false + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + loggerMW := middleware.LoggerWithConfig(cfg, rec) + + e := echo.New() + c := e.NewContext(req, rec) + + middlewareChain := loggerMW(logTestHandler) + require.NoError(t, middlewareChain(c)) + + bufSize := 2000 + loggedData := make([]byte, bufSize) + n, err := rec.Body.Read(loggedData) + require.NoError(t, err) + assert.Less(t, n, bufSize) + // LogCaller was set to false + assert.NotContains(t, string(loggedData), "caller") + + // now log again with LogCaller set to true + cfg.LogCaller = true + loggerMW = middleware.LoggerWithConfig(cfg, rec) + + rec.Flush() + + middlewareChain = loggerMW(logTestHandler) + require.NoError(t, middlewareChain(c)) + + n, err = rec.Body.Read(loggedData) + require.NoError(t, err) + assert.Less(t, n, bufSize) + // logger_test.go:X should match the line where log.Info() is placed in the logTestHandler + assert.Contains(t, string(loggedData[:n]), `"caller":"/app/internal/api/middleware/logger_test.go:17"`) +} diff --git a/internal/api/middleware/no_cache.go b/internal/api/middleware/no_cache.go new file mode 100644 index 0000000..c7c2539 --- /dev/null +++ b/internal/api/middleware/no_cache.go @@ -0,0 +1,91 @@ +package middleware + +// Based on https://github.com/LYY/echo-middleware (MIT License, https://github.com/LYY/echo-middleware/blob/master/LICENSE) +// Ported from Goji's middleware, source: +// https://github.com/zenazn/goji/tree/master/web/middleware (MIT License, https://github.com/zenazn/goji/blob/master/LICENSE) + +import ( + "time" + + "github.com/labstack/echo/v4" + middleware "github.com/labstack/echo/v4/middleware" +) + +type ( + // NoCacheConfig defines the config for nocache middleware. + NoCacheConfig struct { + // Skipper defines a function to skip middleware. + Skipper middleware.Skipper + } +) + +var ( + // Unix epoch time + epoch = time.Unix(0, 0).Format(time.RFC1123) + + // Taken from https://github.com/mytrile/nocache + noCacheHeaders = map[string]string{ + "Expires": epoch, + "Cache-Control": "no-cache, private, max-age=0", + "Pragma": "no-cache", + "X-Accel-Expires": "0", + } + etagHeaders = []string{ + "ETag", + "If-Modified-Since", + "If-Match", + "If-None-Match", + "If-Range", + "If-Unmodified-Since", + } + // DefaultNoCacheConfig is the default nocache middleware config. + DefaultNoCacheConfig = NoCacheConfig{ + Skipper: middleware.DefaultSkipper, + } +) + +// NoCache is a simple piece of middleware that sets a number of HTTP headers to prevent +// a router (or subrouter) from being cached by an upstream proxy and/or client. +// +// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets: +// +// Expires: Thu, 01 Jan 1970 00:00:00 UTC +// Cache-Control: no-cache, private, max-age=0 +// X-Accel-Expires: 0 +// Pragma: no-cache (for HTTP/1.0 proxies/clients) +func NoCache() echo.MiddlewareFunc { + return NoCacheWithConfig(DefaultNoCacheConfig) +} + +// NoCacheWithConfig returns a nocache middleware with config. +func NoCacheWithConfig(config NoCacheConfig) echo.MiddlewareFunc { + // Defaults + if config.Skipper == nil { + config.Skipper = DefaultNoCacheConfig.Skipper + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if config.Skipper(c) { + return next(c) + } + + req := c.Request() + + // Delete any ETag headers that may have been set + for _, v := range etagHeaders { + if req.Header.Get(v) != "" { + req.Header.Del(v) + } + } + + // Set our NoCache headers + res := c.Response() + for k, v := range noCacheHeaders { + res.Header().Set(k, v) + } + + return next(c) + } + } +} diff --git a/internal/api/middleware/noop.go b/internal/api/middleware/noop.go new file mode 100644 index 0000000..b6986e1 --- /dev/null +++ b/internal/api/middleware/noop.go @@ -0,0 +1,11 @@ +package middleware + +import "github.com/labstack/echo/v4" + +func Noop() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(c) + } + } +} diff --git a/internal/api/middleware/recover.go b/internal/api/middleware/recover.go new file mode 100644 index 0000000..6f28461 --- /dev/null +++ b/internal/api/middleware/recover.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "allaboutapps.dev/aw/go-starter/internal/util" + + "github.com/labstack/echo/v4" +) + +func LogErrorFuncWithRequestInfo(c echo.Context, err error, stack []byte) error { + log := util.LogFromContext(c.Request().Context()) + + log.Error().Err(err).Bytes("stack", stack).Msg("PANIC RECOVER") + + return err +} diff --git a/internal/api/middleware/recover_test.go b/internal/api/middleware/recover_test.go new file mode 100644 index 0000000..183f568 --- /dev/null +++ b/internal/api/middleware/recover_test.go @@ -0,0 +1,41 @@ +package middleware_test + +import ( + "io" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/middleware" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/labstack/echo/v4" + echoMiddleware "github.com/labstack/echo/v4/middleware" + + "github.com/stretchr/testify/require" +) + +func TestLogErrorFuncWithRequestInfo(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + path := "/testing-e87bc94c-2d1f-4342-9ec2-f158c63ac6da" + + s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{ + LogErrorFunc: middleware.LogErrorFuncWithRequestInfo, + })) + + s.Echo.POST(path, func(c echo.Context) error { + // trigger the recover middleware by triggering a nil pointer dereference + var val *int + _ = *val + + return c.NoContent(http.StatusNoContent) + }) + + res := test.PerformRequest(t, s, "POST", path, nil, nil) + require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + test.Snapshoter.SaveString(t, string(body)) + }) +} diff --git a/internal/api/providers.go b/internal/api/providers.go new file mode 100644 index 0000000..916ff8e --- /dev/null +++ b/internal/api/providers.go @@ -0,0 +1,81 @@ +package api + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/i18n" + "allaboutapps.dev/aw/go-starter/internal/mailer" + "allaboutapps.dev/aw/go-starter/internal/persistence" + "allaboutapps.dev/aw/go-starter/internal/push" + "allaboutapps.dev/aw/go-starter/internal/push/provider" + "github.com/dropbox/godropbox/time2" + "github.com/rs/zerolog/log" +) + +// PROVIDERS - define here only providers that for various reasons (e.g. cyclic dependency) can't live in their corresponding packages +// or for wrapping providers that only accept sub-configs to prevent the requirements for defining providers for sub-configs. +// https://github.com/google/wire/blob/main/docs/guide.md#defining-providers + +// NewPush creates an instance of the push service and registers the configured push providers. +func NewPush(cfg config.Server, db *sql.DB) (*push.Service, error) { + pusher := push.New(db) + + if cfg.Push.UseFCMProvider { + fcmProvider, err := provider.NewFCM(cfg.FCMConfig) + if err != nil { + return nil, fmt.Errorf("failed to create FCM provider: %w", err) + } + pusher.RegisterProvider(fcmProvider) + } + + if cfg.Push.UseMockProvider { + log.Warn().Msg("Initializing mock push provider") + mockProvider := provider.NewMock(push.ProviderTypeFCM) + pusher.RegisterProvider(mockProvider) + } + + if pusher.GetProviderCount() < 1 { + log.Warn().Msg("No providers registered for push service") + } + + return pusher, nil +} + +func NewClock(t ...*testing.T) time2.Clock { + var clock time2.Clock + + useMock := len(t) > 0 && t[0] != nil + + if useMock { + clock = time2.NewMockClock(time.Now()) + } else { + clock = time2.DefaultClock + } + + return clock +} + +func NewAuthService(config config.Server, db *sql.DB, clock time2.Clock) *auth.Service { + return auth.NewService(config, db, clock) +} + +func NewMailer(config config.Server) (*mailer.Mailer, error) { + return mailer.NewWithConfig(config.Mailer, config.SMTP) +} + +func NewDB(config config.Server) (*sql.DB, error) { + return persistence.NewDB(config.Database) +} + +func NewI18N(config config.Server) (*i18n.Service, error) { + return i18n.New(config.I18n) +} + +func NoTest() []*testing.T { + return nil +} diff --git a/internal/api/router/echo_logger.go b/internal/api/router/echo_logger.go new file mode 100644 index 0000000..6214d7a --- /dev/null +++ b/internal/api/router/echo_logger.go @@ -0,0 +1,13 @@ +package router + +import "github.com/rs/zerolog" + +type echoLogger struct { + level zerolog.Level + log zerolog.Logger +} + +func (l *echoLogger) Write(p []byte) (int, error) { + l.log.WithLevel(l.level).Msgf("%s", p) + return len(p), nil +} diff --git a/internal/api/router/echo_renderer.go b/internal/api/router/echo_renderer.go new file mode 100644 index 0000000..5cd4579 --- /dev/null +++ b/internal/api/router/echo_renderer.go @@ -0,0 +1,27 @@ +package router + +import ( + "fmt" + "html/template" + "io" + + "allaboutapps.dev/aw/go-starter/internal/api/router/templates" + "github.com/labstack/echo/v4" +) + +type echoRenderer struct { + templates map[templates.ViewTemplate]*template.Template +} + +func (t *echoRenderer) Render(writer io.Writer, name string, data interface{}, _ echo.Context) error { + tmplHTML, ok := t.templates[templates.ViewTemplate(name)] + if !ok { + return fmt.Errorf("template not found: %s", name) + } + + if err := tmplHTML.Execute(writer, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} diff --git a/internal/api/router/handlers.go b/internal/api/router/handlers.go new file mode 100644 index 0000000..f208829 --- /dev/null +++ b/internal/api/router/handlers.go @@ -0,0 +1,150 @@ +package router + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" + "github.com/timewasted/go-accept-headers" +) + +var ( + DefaultHTTPErrorHandlerConfig = HTTPErrorHandlerConfig{ + HideInternalServerErrorDetails: true, + } +) + +type HTTPErrorHandlerConfig struct { + HideInternalServerErrorDetails bool +} + +func HTTPErrorHandler() echo.HTTPErrorHandler { + return HTTPErrorHandlerWithConfig(DefaultHTTPErrorHandlerConfig) +} + +func HTTPErrorHandlerWithConfig(config HTTPErrorHandlerConfig) echo.HTTPErrorHandler { + return func(err error, c echo.Context) { + var code int64 + var resultErr error + + var httpError *httperrors.HTTPError + var httpValidationError *httperrors.HTTPValidationError + var echoHTTPError *echo.HTTPError + + switch { + case errors.As(err, &httpError): + code = *httpError.Code + resultErr = httpError + + if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails { + if httpError.Internal == nil { + //nolint:errorlint + httpError.Internal = fmt.Errorf("internal error: %s", httpError) + } + + httpError.Title = swag.String(http.StatusText(http.StatusInternalServerError)) + } + case errors.As(err, &httpValidationError): + code = *httpValidationError.Code + resultErr = httpValidationError + + if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails { + if httpValidationError.Internal == nil { + //nolint:errorlint + httpValidationError.Internal = fmt.Errorf("internal error: %s", httpValidationError) + } + + httpValidationError.Title = swag.String(http.StatusText(http.StatusInternalServerError)) + } + case errors.As(err, &echoHTTPError): + code = int64(echoHTTPError.Code) + + //nolint:nestif + if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails { + if echoHTTPError.Internal == nil { + //nolint:errorlint + echoHTTPError.Internal = fmt.Errorf("internal error: %s", echoHTTPError) + } + + resultErr = &httperrors.HTTPError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(echoHTTPError.Code)), + Title: swag.String(http.StatusText(http.StatusInternalServerError)), + Type: types.PublicHTTPErrorTypeGeneric.Pointer(), + }, + Internal: echoHTTPError.Internal, + } + } else { + msg, ok := echoHTTPError.Message.(string) + if !ok { + if m, errr := json.Marshal(msg); err == nil { + msg = string(m) + } else { + msg = fmt.Sprintf("failed to marshal HTTP error message: %v", errr) + } + } + + resultErr = &httperrors.HTTPError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(echoHTTPError.Code)), + Title: &msg, + Type: types.PublicHTTPErrorTypeGeneric.Pointer(), + }, + Internal: echoHTTPError.Internal, + } + } + default: + code = http.StatusInternalServerError + if config.HideInternalServerErrorDetails { + resultErr = &httperrors.HTTPError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(http.StatusInternalServerError)), + Title: swag.String(http.StatusText(http.StatusInternalServerError)), + Type: types.PublicHTTPErrorTypeGeneric.Pointer(), + }, + + Internal: err, + } + } else { + resultErr = &httperrors.HTTPError{ + PublicHTTPError: types.PublicHTTPError{ + Code: swag.Int64(int64(http.StatusInternalServerError)), + Title: swag.String(err.Error()), + Type: types.PublicHTTPErrorTypeGeneric.Pointer(), + }, + } + } + } + + if !c.Response().Committed { + if c.Request().Method == http.MethodHead { + err = c.NoContent(int(code)) + } else { + err = c.JSON(int(code), resultErr) + } + + if err != nil { + util.LogFromEchoContext(c).Warn().Err(err).AnErr("http_err", err).Msg("Failed to handle HTTP error") + } + } + } +} + +func NotFoundHandler(config config.Server) func(c echo.Context) error { + return func(c echo.Context) error { + accepted := accept.Parse(c.Request().Header.Get(echo.HeaderAccept)) + + if accepted.Accepts(echo.MIMETextHTML) { + return c.HTML(http.StatusNotFound, fmt.Sprintf(`

Page Not Found

The page you are looking for does not exist. Did you mean to visit %s?

`, config.Frontend.BaseURL, config.Frontend.BaseURL)) + } + + return echo.ErrNotFound + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go new file mode 100644 index 0000000..3d70d02 --- /dev/null +++ b/internal/api/router/router.go @@ -0,0 +1,252 @@ +package router + +import ( + "context" + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/handlers" + "allaboutapps.dev/aw/go-starter/internal/api/handlers/constants" + "allaboutapps.dev/aw/go-starter/internal/api/middleware" + "allaboutapps.dev/aw/go-starter/internal/api/router/templates" + "github.com/labstack/echo-contrib/echoprometheus" + "github.com/labstack/echo/v4" + echoMiddleware "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog/log" + + // #nosec G108 - pprof handlers (conditionally made available via http.DefaultServeMux) + "net/http/pprof" +) + +func Init(s *api.Server) error { + s.Echo = echo.New() + + viewsRenderer := &echoRenderer{ + templates: map[templates.ViewTemplate]*template.Template{}, + } + + files, err := os.ReadDir(s.Config.Echo.WebTemplatesViewsBaseDirAbs) + if err != nil { + return fmt.Errorf("failed to read views templates dir: %w", err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + templateName := file.Name() + t, err := template.New(templateName).ParseGlob(filepath.Join(s.Config.Echo.WebTemplatesViewsBaseDirAbs, templateName)) + if err != nil { + return fmt.Errorf("failed to parse template file: %w", err) + } + + viewsRenderer.templates[templates.ViewTemplate(templateName)] = t + } + + s.Echo.Renderer = viewsRenderer + + s.Echo.Debug = s.Config.Echo.Debug + s.Echo.HideBanner = true + s.Echo.Logger.SetOutput(&echoLogger{level: s.Config.Logger.RequestLevel, log: log.With().Str("component", "echo").Logger()}) + echo.NotFoundHandler = NotFoundHandler(s.Config) + + s.Echo.HTTPErrorHandler = HTTPErrorHandlerWithConfig(HTTPErrorHandlerConfig{ + HideInternalServerErrorDetails: s.Config.Echo.HideInternalServerErrorDetails, + }) + + // --- + // General middleware + if s.Config.Management.EnableMetrics { + s.Echo.Use(echoprometheus.NewMiddleware("")) + err := s.Metrics.RegisterMetrics(context.Background()) + if err != nil { + log.Err(err).Msg("Failed to register metrics") + return err + } + } else { + log.Warn().Msg("Disabling metrics middleware due to environment config") + } + + if s.Config.Echo.EnableTrailingSlashMiddleware { + s.Echo.Pre(echoMiddleware.RemoveTrailingSlash()) + } else { + log.Warn().Msg("Disabling trailing slash middleware due to environment config") + } + + if s.Config.Echo.EnableRecoverMiddleware { + s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{ + LogErrorFunc: middleware.LogErrorFuncWithRequestInfo, + })) + } else { + log.Warn().Msg("Disabling recover middleware due to environment config") + } + + if s.Config.Echo.EnableSecureMiddleware { + s.Echo.Use(echoMiddleware.SecureWithConfig(echoMiddleware.SecureConfig{ + Skipper: echoMiddleware.DefaultSecureConfig.Skipper, + XSSProtection: s.Config.Echo.SecureMiddleware.XSSProtection, + ContentTypeNosniff: s.Config.Echo.SecureMiddleware.ContentTypeNosniff, + XFrameOptions: s.Config.Echo.SecureMiddleware.XFrameOptions, + HSTSMaxAge: s.Config.Echo.SecureMiddleware.HSTSMaxAge, + HSTSExcludeSubdomains: s.Config.Echo.SecureMiddleware.HSTSExcludeSubdomains, + ContentSecurityPolicy: s.Config.Echo.SecureMiddleware.ContentSecurityPolicy, + CSPReportOnly: s.Config.Echo.SecureMiddleware.CSPReportOnly, + HSTSPreloadEnabled: s.Config.Echo.SecureMiddleware.HSTSPreloadEnabled, + ReferrerPolicy: s.Config.Echo.SecureMiddleware.ReferrerPolicy, + })) + } else { + log.Warn().Msg("Disabling secure middleware due to environment config") + } + + if s.Config.Echo.EnableRequestIDMiddleware { + s.Echo.Use(echoMiddleware.RequestID()) + } else { + log.Warn().Msg("Disabling request ID middleware due to environment config") + } + + if s.Config.Echo.EnableLoggerMiddleware { + s.Echo.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + Level: s.Config.Logger.RequestLevel, + LogRequestBody: s.Config.Logger.LogRequestBody, + LogRequestHeader: s.Config.Logger.LogRequestHeader, + LogRequestQuery: s.Config.Logger.LogRequestQuery, + LogResponseBody: s.Config.Logger.LogResponseBody, + LogResponseHeader: s.Config.Logger.LogResponseHeader, + LogCaller: s.Config.Logger.LogCaller, + RequestBodyLogSkipper: func(req *http.Request) bool { + // We skip all body logging for auth endpoints as these might contain credentials + if strings.HasPrefix(req.URL.Path, "/api/v1/auth") { + return true + } + + return middleware.DefaultRequestBodyLogSkipper(req) + }, + ResponseBodyLogSkipper: func(req *http.Request, res *echo.Response) bool { + // We skip all body logging for auth endpoints as these might contain credentials + if strings.HasPrefix(req.URL.Path, "/api/v1/auth") { + return true + } + + return middleware.DefaultResponseBodyLogSkipper(req, res) + }, + Skipper: func(c echo.Context) bool { + // We skip logging of readiness and liveness endpoints + switch c.Path() { + case "/-/ready", "/-/healthy": + return true + } + return false + }, + })) + } else { + log.Warn().Msg("Disabling logger middleware due to environment config") + } + + if s.Config.Echo.EnableCORSMiddleware { + s.Echo.Use(echoMiddleware.CORS()) + } else { + log.Warn().Msg("Disabling CORS middleware due to environment config") + } + + if s.Config.Echo.EnableCacheControlMiddleware { + s.Echo.Use(middleware.CacheControl()) + } else { + log.Warn().Msg("Disabling cache control middleware due to environment config") + } + + if s.Config.Pprof.Enable { + pprofAuthMiddleware := middleware.Noop() + + if s.Config.Pprof.EnableManagementKeyAuth { + pprofAuthMiddleware = echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{ + KeyLookup: "query:mgmt-secret", + Validator: func(key string, _ echo.Context) (bool, error) { + return key == s.Config.Management.Secret, nil + }, + }) + } + + s.Echo.GET("/debug/pprof", echo.WrapHandler(http.HandlerFunc(pprof.Index)), pprofAuthMiddleware) + s.Echo.Any("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux), pprofAuthMiddleware) + + log.Warn().Bool("EnableManagementKeyAuth", s.Config.Pprof.EnableManagementKeyAuth).Msg("Pprof http handlers are available at /debug/pprof") + + if s.Config.Pprof.RuntimeBlockProfileRate != 0 { + runtime.SetBlockProfileRate(s.Config.Pprof.RuntimeBlockProfileRate) + log.Warn().Int("RuntimeBlockProfileRate", s.Config.Pprof.RuntimeBlockProfileRate).Msg("Pprof runtime.SetBlockProfileRate") + } + + if s.Config.Pprof.RuntimeMutexProfileFraction != 0 { + runtime.SetMutexProfileFraction(s.Config.Pprof.RuntimeMutexProfileFraction) + log.Warn().Int("RuntimeMutexProfileFraction", s.Config.Pprof.RuntimeMutexProfileFraction).Msg("Pprof runtime.SetMutexProfileFraction") + } + } + + // Add your custom / additional middlewares here. + // see https://echo.labstack.com/middleware + + // --- + // Initialize our general groups and set middleware to use above them + s.Router = &api.Router{ + Routes: nil, // will be populated by handlers.AttachAllRoutes(s) + + // Unsecured base group available at /** + Root: s.Echo.Group(""), + + // Management endpoints, uncacheable, secured by key auth (query param), available at /-/** + Management: s.Echo.Group("/-", echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{ + KeyLookup: "query:mgmt-secret", + Validator: func(key string, _ echo.Context) (bool, error) { + return key == s.Config.Management.Secret, nil + }, + Skipper: func(c echo.Context) bool { + //nolint:gocritic + switch c.Path() { + case "/-/ready": + return true + } + return false + }, + }), middleware.NoCache()), + + // OAuth2, unsecured or secured by bearer auth, available at /api/v1/auth/** + APIV1Auth: s.Echo.Group("/api/v1/auth", middleware.AuthWithConfig(middleware.AuthConfig{ + S: s, + Mode: middleware.AuthModeRequired, + Skipper: func(c echo.Context) bool { + switch c.Path() { + case "/api/v1/auth/forgot-password", + "/api/v1/auth/forgot-password/complete", + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/auth/register", + fmt.Sprintf("/api/v1/auth/register/:%s", constants.RegistrationTokenParam): + return true + } + return false + }, + })), + WellKnown: s.Echo.Group("/.well-known"), + + // Your other endpoints, typically secured by bearer auth, available at /api/v1/** + APIV1Push: s.Echo.Group("/api/v1/push", middleware.Auth(s)), + } + + // --- + // Finally attach our handlers + handlers.AttachAllRoutes(s) + + if s.Config.Management.EnableMetrics { + log.Info().Msg("Metrics enabled and available under /metrics") + s.Echo.GET("/metrics", echoprometheus.NewHandler()) + } + + return nil +} diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go new file mode 100644 index 0000000..525ed14 --- /dev/null +++ b/internal/api/router/router_test.go @@ -0,0 +1,128 @@ +package router_test + +import ( + "fmt" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/metrics/users" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPprofEnabled(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + + // these are typically our default values, however we force set them here to ensure those are set while test execution. + config.Pprof.Enable = true + config.Pprof.EnableManagementKeyAuth = true + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + // heap (test any) + res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 200, res.Result().StatusCode) + + // index + res = test.PerformRequest(t, s, "GET", "/debug/pprof?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 200, res.Result().StatusCode) + + res = test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret=wrongsecret", nil, nil) + require.Equal(t, 401, res.Result().StatusCode) + }) +} + +func TestPprofEnabledNoAuth(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + + // these are typically our default values, however we force set them here to ensure those are set while test execution. + config.Pprof.Enable = true + config.Pprof.EnableManagementKeyAuth = false + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?", nil, nil) + require.Equal(t, 200, res.Result().StatusCode) + }) +} + +func TestPprofDisabled(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Pprof.Enable = false + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil) + require.Equal(t, 404, res.Result().StatusCode) + }) +} + +func TestMiddlewaresDisabled(t *testing.T) { + // disable all + config := config.DefaultServiceConfigFromEnv() + config.Echo.EnableCORSMiddleware = false + config.Echo.EnableLoggerMiddleware = false + config.Echo.EnableRecoverMiddleware = false + config.Echo.EnableRequestIDMiddleware = false + config.Echo.EnableSecureMiddleware = false + config.Echo.EnableTrailingSlashMiddleware = false + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil) + require.Equal(t, 200, res.Result().StatusCode) + }) +} + +func TestMetricsEnabled(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + config.Management.EnableMetrics = true + + test.WithTestServerConfigurable(t, config, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + result := res.Body.String() + + // expect custom metric for the total user count + expectedTotalUserCount, err := models.Users().Count(t.Context(), s.DB) + require.NoError(t, err) + + assert.Contains(t, result, fmt.Sprintf("%s %d", users.MetricNameTotalUsers, expectedTotalUserCount)) + + // expect sqlstats metrics + assert.Contains(t, result, "go_sql_stats_connections") + }) +} + +func TestMetricsDisabled(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil) + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) + }) +} + +func TestNotFound(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + t.Run("AcceptApplicationJSON", func(t *testing.T) { + headers := http.Header{} + headers.Set(echo.HeaderAccept, echo.MIMEApplicationJSON) + + res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers) + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) + + test.Snapshoter.Save(t, res.Body.String()) + }) + + t.Run("AcceptTextHTML", func(t *testing.T) { + headers := http.Header{} + headers.Set(echo.HeaderAccept, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + + res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers) + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) + + test.Snapshoter.Save(t, res.Body.String()) + }) + }) +} diff --git a/internal/api/router/templates/templates.go b/internal/api/router/templates/templates.go new file mode 100644 index 0000000..323f1df --- /dev/null +++ b/internal/api/router/templates/templates.go @@ -0,0 +1,11 @@ +package templates + +type ViewTemplate string + +const ( + ViewTemplateAccountConfirmation ViewTemplate = "account_confirmation.html.tmpl" +) + +func (vt ViewTemplate) String() string { + return string(vt) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..edd25f6 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,154 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/data/local" + "allaboutapps.dev/aw/go-starter/internal/i18n" + "allaboutapps.dev/aw/go-starter/internal/mailer" + "allaboutapps.dev/aw/go-starter/internal/metrics" + "allaboutapps.dev/aw/go-starter/internal/push" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/dropbox/godropbox/time2" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + + // Import postgres driver for database/sql package + _ "github.com/lib/pq" +) + +type Router struct { + Routes []*echo.Route + Root *echo.Group + Management *echo.Group + APIV1Auth *echo.Group + APIV1Push *echo.Group + WellKnown *echo.Group +} + +// Server is a central struct keeping all the dependencies. +// It is initialized with wire, which handles making the new instances of the components +// in the right order. To add a new component, 3 steps are required: +// - declaring it in this struct +// - adding a provider function in providers.go +// - adding the provider's function name to the arguments of wire.Build() in wire.go +// +// Components labeled as `wire:"-"` will be skipped and have to be initialized after the InitNewServer* call. +// For more information about wire refer to https://pkg.go.dev/github.com/google/wire +type Server struct { + // skip wire: + // -> initialized with router.Init(s) function + Echo *echo.Echo `wire:"-"` + Router *Router `wire:"-"` + + Config config.Server + DB *sql.DB + Mailer *mailer.Mailer + Push *push.Service + I18n *i18n.Service + Clock time2.Clock + Auth AuthService + Local *local.Service + Metrics *metrics.Service +} + +// newServerWithComponents is used by wire to initialize the server components. +// Components not listed here won't be handled by wire and should be initialized separately. +// Components which shouldn't be handled must be labeled `wire:"-"` in Server struct. +func newServerWithComponents( + cfg config.Server, + db *sql.DB, + mail *mailer.Mailer, + pusher *push.Service, + i18n *i18n.Service, + clock time2.Clock, + auth AuthService, + local *local.Service, + metrics *metrics.Service, +) *Server { + return &Server{ + Config: cfg, + DB: db, + Mailer: mail, + Push: pusher, + I18n: i18n, + Clock: clock, + Auth: auth, + Local: local, + Metrics: metrics, + } +} + +type AuthService interface { + GetAppUserProfile(ctx context.Context, id string) (*dto.AppUserProfile, error) + InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error) + Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error) + Logout(ctx context.Context, request dto.LogoutRequest) error + Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error) + Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error) + CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error) + DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error + ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error) + UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error) +} + +func NewServer(config config.Server) *Server { + s := &Server{ + Config: config, + } + + return s +} + +func (s *Server) Ready() bool { + if err := util.IsStructInitialized(s); err != nil { + log.Debug().Err(err).Msg("Server is not fully initialized") + return false + } + + return true +} + +func (s *Server) Start() error { + if !s.Ready() { + return errors.New("server is not ready") + } + + if err := s.Echo.Start(s.Config.Echo.ListenAddress); err != nil { + return fmt.Errorf("failed to start echo server: %w", err) + } + + return nil +} + +func (s *Server) Shutdown(ctx context.Context) []error { + log.Warn().Msg("Shutting down server") + + var errs []error + + if s.DB != nil { + log.Debug().Msg("Closing database connection") + + if err := s.DB.Close(); err != nil && !errors.Is(err, sql.ErrConnDone) { + log.Error().Err(err).Msg("Failed to close database connection") + errs = append(errs, err) + } + } + + if s.Echo != nil { + log.Debug().Msg("Shutting down echo server") + + if err := s.Echo.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error().Err(err).Msg("Failed to shutdown echo server") + errs = append(errs, err) + } + } + + return errs +} diff --git a/internal/api/wire.go b/internal/api/wire.go new file mode 100644 index 0000000..4c11c51 --- /dev/null +++ b/internal/api/wire.go @@ -0,0 +1,52 @@ +//go:build wireinject + +package api + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/data/local" + "allaboutapps.dev/aw/go-starter/internal/metrics" + "github.com/google/wire" +) + +// INJECTORS - https://github.com/google/wire/blob/main/docs/guide.md#injectors + +// serviceSet groups the default set of providers that are required for initing a server +var serviceSet = wire.NewSet( + newServerWithComponents, + NewPush, + NewMailer, + NewI18N, + authServiceSet, + local.NewService, + metrics.New, + NewClock, +) + +var authServiceSet = wire.NewSet( + NewAuthService, + wire.Bind(new(AuthService), new(*auth.Service)), +) + +// InitNewServer returns a new Server instance. +func InitNewServer( + _ config.Server, +) (*Server, error) { + wire.Build(serviceSet, NewDB, NoTest) + return new(Server), nil +} + +// InitNewServerWithDB returns a new Server instance with the given DB instance. +// All the other components are initialized via go wire according to the configuration. +func InitNewServerWithDB( + _ config.Server, + _ *sql.DB, + t ...*testing.T, +) (*Server, error) { + wire.Build(serviceSet) + return new(Server), nil +} diff --git a/internal/api/wire_gen.go b/internal/api/wire_gen.go new file mode 100644 index 0000000..a505ccd --- /dev/null +++ b/internal/api/wire_gen.go @@ -0,0 +1,94 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package api + +import ( + "allaboutapps.dev/aw/go-starter/internal/auth" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/data/local" + "allaboutapps.dev/aw/go-starter/internal/metrics" + "database/sql" + "github.com/google/wire" + "testing" +) + +import ( + _ "github.com/lib/pq" +) + +// Injectors from wire.go: + +// InitNewServer returns a new Server instance. +func InitNewServer(server config.Server) (*Server, error) { + db, err := NewDB(server) + if err != nil { + return nil, err + } + mailer, err := NewMailer(server) + if err != nil { + return nil, err + } + service, err := NewPush(server, db) + if err != nil { + return nil, err + } + i18nService, err := NewI18N(server) + if err != nil { + return nil, err + } + v := NoTest() + clock := NewClock(v...) + authService := NewAuthService(server, db, clock) + localService := local.NewService(server, db, clock) + metricsService, err := metrics.New(server, db) + if err != nil { + return nil, err + } + apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService) + return apiServer, nil +} + +// InitNewServerWithDB returns a new Server instance with the given DB instance. +// All the other components are initialized via go wire according to the configuration. +func InitNewServerWithDB(server config.Server, db *sql.DB, t ...*testing.T) (*Server, error) { + mailer, err := NewMailer(server) + if err != nil { + return nil, err + } + service, err := NewPush(server, db) + if err != nil { + return nil, err + } + i18nService, err := NewI18N(server) + if err != nil { + return nil, err + } + clock := NewClock(t...) + authService := NewAuthService(server, db, clock) + localService := local.NewService(server, db, clock) + metricsService, err := metrics.New(server, db) + if err != nil { + return nil, err + } + apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService) + return apiServer, nil +} + +// wire.go: + +// serviceSet groups the default set of providers that are required for initing a server +var serviceSet = wire.NewSet( + newServerWithComponents, + NewPush, + NewMailer, + NewI18N, + authServiceSet, local.NewService, metrics.New, NewClock, +) + +var authServiceSet = wire.NewSet( + NewAuthService, wire.Bind(new(AuthService), new(*auth.Service)), +) diff --git a/internal/auth/constants.go b/internal/auth/constants.go new file mode 100644 index 0000000..c1b79bd --- /dev/null +++ b/internal/auth/constants.go @@ -0,0 +1,5 @@ +package auth + +const ( + TokenTypeBearer = "bearer" +) diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 0000000..a91db78 --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +// EnrichContextWithCredentials stores the provided credentials in the form of user and access token used for authentication +// in the give context and updates the logger associated with ctx to include the user's ID. +func EnrichContextWithCredentials(ctx context.Context, result Result) context.Context { + // Retrieve current logger associated with context and extend it ID of authenticated user + l := util.LogFromContext(ctx).With().Str("userID", result.User.ID).Logger() + ctx = l.WithContext(ctx) + + // Store authenticated user's instance in context + ctx = context.WithValue(ctx, util.CTXKeyUser, result.User) + // Store access token used for authentication in context + ctx = context.WithValue(ctx, util.CTXKeyAccessToken, result.Token) + + return ctx +} + +// EnrichEchoContextWithCredentials stores the provided credentials in the form of user and access token user for authentication +// in the given echo context's request and updates the logger associated with c to include the user's ID. +func EnrichEchoContextWithCredentials(c echo.Context, result Result) echo.Context { + // Get current context and enrich it with credentials + req := c.Request() + ctx := EnrichContextWithCredentials(req.Context(), result) + + // Set updated request with enriched context in echo context + c.SetRequest(req.WithContext(ctx)) + + return c +} + +// UserFromContext returns the user model of the currently authenticated user from a context. If no authentication was provided +// or the current context does not carry any user information, nil will be returned instead. +func UserFromContext(ctx context.Context) *dto.User { + u := ctx.Value(util.CTXKeyUser) + if u == nil { + return nil + } + + user, ok := u.(*dto.User) + if !ok { + return nil + } + + return user +} + +// UserFromEchoContext returns the user model of the currently authenticated user from an echo context. If no authentication was +// provided or the current echo context does not carry any user information, nil will be returned instead. +func UserFromEchoContext(c echo.Context) *dto.User { + return UserFromContext(c.Request().Context()) +} + +// AccessTokenFromContext returns the access token model of the token used to authentication from a context. If no authentication was +// provided or the current context does not carry any access token information, nil will be returned instead. +func AccessTokenFromContext(ctx context.Context) *string { + t := ctx.Value(util.CTXKeyAccessToken) + if t == nil { + return nil + } + + token, ok := t.(string) + if !ok { + return nil + } + + return swag.String(token) +} + +// AccessTokenFromEchoContext returns the access token model of the token used to authentication from an echo context. If no authentication +// was provided or the current context does not carry any access token information, nil will be returned instead. +func AccessTokenFromEchoContext(c echo.Context) *string { + return AccessTokenFromContext(c.Request().Context()) +} diff --git a/internal/auth/errors.go b/internal/auth/errors.go new file mode 100644 index 0000000..d456483 --- /dev/null +++ b/internal/auth/errors.go @@ -0,0 +1,7 @@ +package auth + +import "errors" + +var ( + ErrNotFound = errors.New("not found") +) diff --git a/internal/auth/scopes.go b/internal/auth/scopes.go new file mode 100644 index 0000000..d4ad6ba --- /dev/null +++ b/internal/auth/scopes.go @@ -0,0 +1,11 @@ +package auth + +type Scope string + +const ( + ScopeApp Scope = "app" +) + +func (s Scope) String() string { + return string(s) +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..0673f48 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,620 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/data/mapper" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "allaboutapps.dev/aw/go-starter/internal/util/hashing" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/dropbox/godropbox/time2" + "github.com/labstack/echo/v4" +) + +type Service struct { + config config.Server + db *sql.DB + clock time2.Clock +} + +func NewService(config config.Server, db *sql.DB, clock time2.Clock) *Service { + return &Service{ + config: config, + db: db, + clock: clock, + } +} + +func (s *Service) GetAppUserProfile(ctx context.Context, userID string) (*dto.AppUserProfile, error) { + log := util.LogFromContext(ctx).With().Str("userID", userID).Logger() + + aup, err := models.AppUserProfiles( + models.AppUserProfileWhere.UserID.EQ(userID), + ).One(ctx, s.db) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("AppUserProfile not found") + return nil, ErrNotFound + } + + log.Err(err).Msg("Failed to get AppUserProfile") + return nil, err + } + + return mapper.LocalAppUserProfileToDTO(aup).Ptr(), nil +} + +func (s *Service) UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error) { + log := util.LogFromContext(ctx).With().Str("userID", request.User.ID).Logger() + + if !request.User.IsActive { + log.Debug().Msg("User is deactivated, rejecting password change") + return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated + } + + if !request.User.PasswordHash.Valid { + log.Debug().Msg("Failed to update user password, user is missing password") + return dto.LoginResult{}, httperrors.ErrForbiddenNotLocalUser + } + + if !request.SkipCurrentPasswordVerification { + match, err := hashing.ComparePasswordAndHash(request.CurrentPassword, request.User.PasswordHash.String) + if err != nil { + log.Err(err).Msg("Failed to compare password with stored hash") + return dto.LoginResult{}, err + } + + if !match { + log.Debug().Msg("Failed to update user password, provided password does not match stored hash") + return dto.LoginResult{}, echo.ErrUnauthorized + } + } + + hash, err := hashing.HashPassword(request.NewPassword, hashing.DefaultArgon2Params) + if err != nil { + log.Err(err).Msg("Failed to hash new password") + return dto.LoginResult{}, httperrors.ErrBadRequestInvalidPassword + } + + var result dto.LoginResult + if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + request.User.PasswordHash = null.StringFrom(hash) + + user := request.User.ToModels() + + if _, err := user.Update(ctx, exec, boil.Whitelist(models.UserColumns.Password, models.UserColumns.UpdatedAt)); err != nil { + log.Err(err).Msg("Failed to update user") + return err + } + + result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{ + User: request.User, + InvalidateExistingTokens: true, + }) + if err != nil { + log.Err(err).Msg("Failed to authenticate user after password change") + return err + } + + return nil + }); err != nil { + log.Debug().Err(err).Msg("Failed to change password") + return dto.LoginResult{}, err + } + + return result, nil +} + +func (s *Service) ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error) { + log := util.LogFromContext(ctx).With().Logger() + + passwordResetToken, err := models.PasswordResetTokens( + models.PasswordResetTokenWhere.Token.EQ(request.ResetToken), + qm.Load(models.PasswordResetTokenRels.User), + ).One(ctx, s.db) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("Password reset token not found") + return dto.LoginResult{}, httperrors.ErrNotFoundTokenNotFound + } + + log.Err(err).Msg("Failed to load password reset token") + return dto.LoginResult{}, err + } + + if s.clock.Now().After(passwordResetToken.ValidUntil) { + log.Debug().Time("validUntil", passwordResetToken.ValidUntil).Msg("Password reset token is no longer valid, rejecting password reset") + return dto.LoginResult{}, httperrors.ErrConflictTokenExpired + } + + return s.UpdatePassword(ctx, dto.UpdatePasswordRequest{ + User: mapper.LocalUserToDTO(passwordResetToken.R.User), + NewPassword: request.NewPassword, + SkipCurrentPasswordVerification: true, + }) +} + +func (s *Service) InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error) { + log := util.LogFromContext(ctx).With().Str("username", request.Username.String()).Logger() + + user, err := models.Users( + models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())), + ).One(ctx, s.db) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("User not found") + return dto.InitPasswordResetResult{}, nil + } + + log.Err(err).Err(err).Msg("Failed to load user") + return dto.InitPasswordResetResult{}, err + } + + if !user.IsActive { + log.Debug().Msg("User is deactivated, skipping password reset") + return dto.InitPasswordResetResult{}, nil + } + + if !user.Password.Valid { + log.Debug().Msg("User is missing password, skipping password reset") + return dto.InitPasswordResetResult{}, nil + } + + if s.config.Auth.PasswordResetTokenDebounceDuration > 0 { + resetTokenInDebounceTimeExists, err := user.PasswordResetTokens( + models.PasswordResetTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.PasswordResetTokenDebounceDuration)), + models.PasswordResetTokenWhere.ValidUntil.GT(s.clock.Now()), + ).Exists(ctx, s.db) + if err != nil { + log.Err(err).Msg("Failed to check for existing password reset token") + return dto.InitPasswordResetResult{}, err + } + + if resetTokenInDebounceTimeExists { + log.Debug().Msg("Password reset token exists within debounce time, not sending new one") + return dto.InitPasswordResetResult{}, nil + } + } + + var result dto.InitPasswordResetResult + if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + passwordResetToken, err := user.PasswordResetTokens( + models.PasswordResetTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.PasswordResetTokenReuseDuration)), + models.PasswordResetTokenWhere.ValidUntil.GT(s.clock.Now()), + ).One(ctx, exec) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("No valid password reset token exists, creating new one") + + passwordResetToken = &models.PasswordResetToken{ + UserID: user.ID, + ValidUntil: s.clock.Now().Add(s.config.Auth.PasswordResetTokenValidity), + } + + if err := passwordResetToken.Insert(ctx, exec, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert password reset token") + return err + } + } else { + log.Err(err).Msg("Failed to check for existing valid password reset token") + return err + } + } + + result.ResetToken = null.StringFrom(passwordResetToken.Token) + + return nil + }); err != nil { + log.Debug().Err(err).Msg("Failed to initiate password reset") + return dto.InitPasswordResetResult{}, err + } + + return result, nil +} + +func (s *Service) Logout(ctx context.Context, request dto.LogoutRequest) error { + log := util.LogFromContext(ctx) + + if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + if _, err := models.AccessTokens(models.AccessTokenWhere.Token.EQ(request.AccessToken)).DeleteAll(ctx, exec); err != nil { + log.Err(err).Msg("Failed to delete access token") + return err + } + + if request.RefreshToken.IsZero() { + return nil + } + + if _, err := models.RefreshTokens(models.RefreshTokenWhere.Token.EQ(request.RefreshToken.String)).DeleteAll(ctx, exec); err != nil { + log.Err(err).Msg("Failed to delete refresh token") + return err + } + + return nil + }); err != nil { + log.Debug().Err(err).Msg("Failed to process logout") + return err + } + + return nil +} + +func (s *Service) Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error) { + log := util.LogFromContext(ctx) + + user, err := models.Users( + models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())), + ).One(ctx, s.db) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("User not found") + } + + log.Err(err).Msg("Failed to load user") + + return dto.LoginResult{}, echo.ErrUnauthorized + } + + if !user.IsActive { + log.Debug().Msg("User is deactivated, rejecting authentication") + return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated + } + + if !user.Password.Valid { + log.Debug().Msg("User is missing password, forbidding authentication") + return dto.LoginResult{}, echo.ErrUnauthorized + } + + match, err := hashing.ComparePasswordAndHash(request.Password, user.Password.String) + if err != nil { + log.Debug().Err(err).Msg("Failed to compare password with stored hash") + return dto.LoginResult{}, echo.ErrUnauthorized + } + + if !match { + log.Debug().Msg("Provided password does not match stored hash") + return dto.LoginResult{}, echo.ErrUnauthorized + } + + var result dto.LoginResult + err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + var err error + result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{ + User: mapper.LocalUserToDTO(user), + }) + if err != nil { + log.Err(err).Msg("Failed to authenticate user") + return err + } + + return nil + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to authenticate user") + return dto.LoginResult{}, err + } + + return result, nil +} + +func (s *Service) Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error) { + log := util.LogFromContext(ctx) + + oldRefreshToken, err := models.RefreshTokens( + models.RefreshTokenWhere.Token.EQ(request.RefreshToken), + qm.Load(models.RefreshTokenRels.User), + ).One(ctx, s.db) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("Refresh token not found") + return dto.LoginResult{}, echo.ErrUnauthorized + } + + log.Err(err).Msg("Failed to load refresh token") + return dto.LoginResult{}, err + } + + user := oldRefreshToken.R.User + + if !user.IsActive { + log.Debug().Msg("User is deactivated, rejecting token refresh") + return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated + } + + var result dto.LoginResult + err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + _, err = oldRefreshToken.Delete(ctx, exec) + if err != nil { + log.Err(err).Msg("Failed to delete old refresh token") + return err + } + + result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{ + User: mapper.LocalUserToDTO(user), + InvalidateExistingTokens: false, + }) + if err != nil { + log.Err(err).Msg("Failed to authenticate user") + return err + } + + return nil + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to refresh token") + return dto.LoginResult{}, err + } + + return result, nil +} + +func (s *Service) Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error) { + log := util.LogFromContext(ctx).With().Str("username", request.Username.String()).Logger() + + user, err := models.Users( + models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())), + ).One(ctx, s.db) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Err(err).Msg("Failed to check whether user exists") + return dto.RegisterResult{}, err + } + + if user != nil { + if !user.RequiresConfirmation { + log.Debug().Msg("User with given username already exists") + return dto.RegisterResult{}, httperrors.ErrConflictUserAlreadyExists + } + + confirmationTokenInDebounceTimeExists, err := user.ConfirmationTokens( + models.ConfirmationTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.ConfirmationTokenDebounceDuration)), + models.ConfirmationTokenWhere.ValidUntil.GT(s.clock.Now()), + ).Exists(ctx, s.db) + if err != nil { + log.Err(err).Msg("Failed to check for existing confirmation token") + return dto.RegisterResult{}, err + } + + if confirmationTokenInDebounceTimeExists { + return dto.RegisterResult{ + RequiresConfirmation: user.RequiresConfirmation, + }, nil + } + + confirmationToken := models.ConfirmationToken{ + UserID: user.ID, + ValidUntil: s.clock.Now().Add(s.config.Auth.ConfirmationTokenValidity), + } + + if err := confirmationToken.Insert(ctx, s.db, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert confirmation token") + return dto.RegisterResult{}, err + } + + return dto.RegisterResult{ + RequiresConfirmation: user.RequiresConfirmation, + ConfirmationToken: null.StringFrom(confirmationToken.Token), + }, nil + } + + hash, err := hashing.HashPassword(request.Password, hashing.DefaultArgon2Params) + if err != nil { + log.Err(err).Msg("Failed to hash user password") + return dto.RegisterResult{}, httperrors.ErrBadRequestInvalidPassword + } + + result := dto.RegisterResult{ + RequiresConfirmation: s.config.Auth.RegistrationRequiresConfirmation, + } + + if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + user := &models.User{ + Username: null.StringFrom(request.Username.String()), + Password: null.StringFrom(hash), + LastAuthenticatedAt: null.TimeFrom(s.clock.Now()), + IsActive: !result.RequiresConfirmation, + RequiresConfirmation: result.RequiresConfirmation, + Scopes: s.config.Auth.DefaultUserScopes, + } + + if err := user.Insert(ctx, exec, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert user") + return err + } + + appUserProfile := models.AppUserProfile{ + UserID: user.ID, + } + + if err := appUserProfile.Insert(ctx, exec, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert app user profile") + return err + } + + if result.RequiresConfirmation { + confirmationToken := models.ConfirmationToken{ + UserID: user.ID, + ValidUntil: s.clock.Now().Add(s.config.Auth.ConfirmationTokenValidity), + } + + if err := confirmationToken.Insert(ctx, exec, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert confirmation token") + return err + } + + result.ConfirmationToken = null.StringFrom(confirmationToken.Token) + } + + return nil + }); err != nil { + log.Debug().Err(err).Msg("Failed to register user") + return dto.RegisterResult{}, err + } + + return result, nil +} + +func (s *Service) DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error { + log := util.LogFromContext(ctx) + + if !request.User.IsActive { + log.Debug().Msg("User is deactivated, rejecting deletion") + return httperrors.ErrForbiddenUserDeactivated + } + + if !request.User.PasswordHash.Valid { + log.Debug().Msg("Failed to delete user account, user is missing password") + return httperrors.ErrForbiddenNotLocalUser + } + + match, err := hashing.ComparePasswordAndHash(request.CurrentPassword, request.User.PasswordHash.String) + if err != nil { + log.Debug().Err(err).Msg("Failed to compare password with stored hash") + return echo.ErrUnauthorized + } + + if !match { + log.Debug().Msg("Provided password does not match stored hash") + return echo.ErrUnauthorized + } + + // delete the user and all related data + err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + _, err = models.Users( + models.UserWhere.ID.EQ(request.User.ID), + ).DeleteAll(ctx, exec) + if err != nil { + log.Err(err).Msg("Failed to delete user") + return err + } + + return nil + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to delete user account") + return err + } + + return nil +} + +func (s *Service) CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error) { + log := util.LogFromContext(ctx) + + var result dto.LoginResult + err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + confirmationToken, err := models.ConfirmationTokens( + models.ConfirmationTokenWhere.Token.EQ(request.ConfirmationToken), + models.ConfirmationTokenWhere.ValidUntil.GT(s.clock.Now()), + qm.Load(models.ConfirmationTokenRels.User), + ).One(ctx, s.db) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Err(err).Msg("Confirmation token not found") + return httperrors.ErrNotFoundTokenNotFound + } + + log.Err(err).Msg("Failed to load confirmation token") + return err + } + + user := confirmationToken.R.User + if user.IsActive || !user.RequiresConfirmation { + log.Debug().Msg("User already active, skipping confirmation") + return nil + } + + user.IsActive = true + user.RequiresConfirmation = false + if _, err := user.Update(ctx, exec, boil.Whitelist(models.UserColumns.IsActive, models.UserColumns.RequiresConfirmation, models.UserColumns.UpdatedAt)); err != nil { + log.Err(err).Msg("Failed to update user") + return err + } + + if _, err := confirmationToken.Delete(ctx, exec); err != nil { + log.Err(err).Msg("Failed to delete confirmation token") + return err + } + + result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{ + User: mapper.LocalUserToDTO(confirmationToken.R.User), + }) + if err != nil { + log.Err(err).Msg("Failed to authenticate user") + return err + } + + return nil + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to complete registration") + return dto.LoginResult{}, err + } + + return result, nil +} + +func (s *Service) authenticateUser(ctx context.Context, exec boil.ContextExecutor, request dto.AuthenticateUserRequest) (dto.LoginResult, error) { + log := util.LogFromContext(ctx) + + result := dto.LoginResult{ + TokenType: TokenTypeBearer, + ExpiresIn: int64(s.config.Auth.AccessTokenValidity.Seconds()), + } + + if request.InvalidateExistingTokens { + if _, err := models.AccessTokens( + models.AccessTokenWhere.UserID.EQ(request.User.ID), + ).DeleteAll(ctx, exec); err != nil { + log.Err(err).Msg("Failed to delete existing access tokens") + return dto.LoginResult{}, err + } + + if _, err := models.RefreshTokens( + models.RefreshTokenWhere.UserID.EQ(request.User.ID), + ).DeleteAll(ctx, exec); err != nil { + log.Err(err).Msg("Failed to delete existing refresh tokens") + return dto.LoginResult{}, err + } + } + + accessToken := models.AccessToken{ + ValidUntil: s.clock.Now().Add(s.config.Auth.AccessTokenValidity), + UserID: request.User.ID, + } + + if err := accessToken.Insert(ctx, exec, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert access token") + return dto.LoginResult{}, err + } + + refreshToken := models.RefreshToken{ + UserID: request.User.ID, + } + + if err := refreshToken.Insert(ctx, exec, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert refresh token") + return dto.LoginResult{}, err + } + + u := request.User.ToModels() + u.LastAuthenticatedAt = null.TimeFrom(s.clock.Now()) + + if _, err := u.Update(ctx, exec, boil.Whitelist(models.UserColumns.LastAuthenticatedAt, models.UserColumns.UpdatedAt)); err != nil { + log.Err(err).Msg("Failed to update user last authenticated time") + return dto.LoginResult{}, err + } + + result.AccessToken = accessToken.Token + result.RefreshToken = refreshToken.Token + + return result, nil +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 0000000..79f811b --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,14 @@ +package auth + +import ( + "time" + + "allaboutapps.dev/aw/go-starter/internal/data/dto" +) + +type Result struct { + Token string + User *dto.User + ValidUntil time.Time + Scopes []string +} diff --git a/internal/config/build_args.go b/internal/config/build_args.go new file mode 100644 index 0000000..7972bc2 --- /dev/null +++ b/internal/config/build_args.go @@ -0,0 +1,18 @@ +package config + +import "fmt" + +// The following vars are automatically injected via -ldflags. +// See Makefile target "make go-build" and make var $(LDFLAGS). +// No need to change them here. +// https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications +var ( + ModuleName = "build.local/misses/ldflags" // e.g. "allaboutapps.dev/aw/go-starter" + Commit = "< 40 chars git commit hash via ldflags >" // e.g. "59cb7684dd0b0f38d68cd7db657cb614feba8f7e" + BuildDate = "1970-01-01T00:00:00+00:00" // e.g. "1970-01-01T00:00:00+00:00" +) + +// GetFormattedBuildArgs returns string representation of buildsargs set via ldflags " @ ()" +func GetFormattedBuildArgs() string { + return fmt.Sprintf("%v @ %v (%v)", ModuleName, Commit, BuildDate) +} diff --git a/internal/config/db_config.go b/internal/config/db_config.go new file mode 100644 index 0000000..f8d51f0 --- /dev/null +++ b/internal/config/db_config.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" +) + +// The DatabaseMigrationTable name is baked into the binary +// This setting should always be in sync with dbconfig.yml, sqlboiler.toml and the live database (e.g. to be able to test producation dumps locally) +const DatabaseMigrationTable = "migrations" + +// The DatabaseMigrationFolder (folder with all *.sql migrations). +// This settings should always be in sync with dbconfig.yaml and Dockerfile (the final app stage). +// It's expected that the migrations folder lives at the root of this project or right next to the app binary. +var DatabaseMigrationFolder = filepath.Join(util.GetProjectRootDir(), "/migrations") + +type Database struct { + Host string + Port int + Username string + Password string `json:"-"` // sensitive + Database string + AdditionalParams map[string]string `json:"additionalParams,omitempty"` // Optional additional connection parameters mapped into the connection string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration +} + +// ConnectionString generates a connection string to be passed to sql.Open or equivalents, assuming Postgres syntax +func (c Database) ConnectionString() string { + var builder strings.Builder + builder.WriteString(fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", c.Host, c.Port, c.Username, c.Password, c.Database)) + + if _, ok := c.AdditionalParams["sslmode"]; !ok { + builder.WriteString(" sslmode=disable") + } + + if len(c.AdditionalParams) > 0 { + params := make([]string, 0, len(c.AdditionalParams)) + for param := range c.AdditionalParams { + params = append(params, param) + } + + sort.Strings(params) + + for _, param := range params { + fmt.Fprintf(&builder, " %s=%s", param, c.AdditionalParams[param]) + } + } + + return builder.String() +} diff --git a/internal/config/db_config_test.go b/internal/config/db_config_test.go new file mode 100644 index 0000000..7974d11 --- /dev/null +++ b/internal/config/db_config_test.go @@ -0,0 +1,68 @@ +package config_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" +) + +// via https://github.com/allaboutapps/integresql/blob/master/pkg/manager/database_config_test.go +func TestDatabaseConnectionString(t *testing.T) { + tests := []struct { + name string + config config.Database + want string + }{ + { + name: "Simple", + config: config.Database{ + Host: "localhost", + Port: 5432, + Username: "simple", + Password: "database_config", + Database: "simple_database_config", + }, + want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config sslmode=disable", + }, + { + name: "SSLMode", + config: config.Database{ + Host: "localhost", + Port: 5432, + Username: "simple", + Password: "database_config", + Database: "simple_database_config", + AdditionalParams: map[string]string{ + "sslmode": "prefer", + }, + }, + want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config sslmode=prefer", + }, + { + name: "Complex", + config: config.Database{ + Host: "localhost", + Port: 5432, + Username: "simple", + Password: "database_config", + Database: "simple_database_config", + AdditionalParams: map[string]string{ + "connect_timeout": "10", + "sslmode": "verify-full", + "sslcert": "/app/certs/pg.pem", + "sslkey": "/app/certs/pg.key", + "sslrootcert": "/app/certs/pg_root.pem", + }, + }, + want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config connect_timeout=10 sslcert=/app/certs/pg.pem sslkey=/app/certs/pg.key sslmode=verify-full sslrootcert=/app/certs/pg_root.pem", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.config.ConnectionString(); got != tt.want { + t.Errorf("invalid connection string, got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/config/dot_env.go b/internal/config/dot_env.go new file mode 100644 index 0000000..634b992 --- /dev/null +++ b/internal/config/dot_env.go @@ -0,0 +1,71 @@ +package config + +import ( + "errors" + "fmt" + "os" + + "github.com/rs/zerolog/log" + "github.com/subosito/gotenv" +) + +// os.SetEnv func signature +type envSetter = func(key string, value string) error + +// DotEnvTryLoad forcefully overrides ENV variables through **a maybe available** .env file. +// +// This function will always remain silent if a .env file does not exist! +// If we successfully apply an ENV file, we will log a warning. +// If there are any other errors, we will panic! +// +// This mechanism should only be used **locally** to easily inject (gitignored) +// secrets into your ENV. Non-existing .env files are actually the **best case**. +// +// When running normally (not within tests): +// DotEnvTryLoad("/path/tp/my.env.local", os.SetEnv) +// +// For tests (and autoreset) use t.Setenv: +// DotEnvTryLoad("/path/to/my.env.test.local", func(k string, v string) error { t.Setenv(k, v); return nil }) +func DotEnvTryLoad(absolutePathToEnvFile string, setEnvFn envSetter) { + err := DotEnvLoad(absolutePathToEnvFile, setEnvFn) + + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Panic().Err(err).Str("envFile", absolutePathToEnvFile).Msg(".env parse error!") + } + } else { + log.Warn().Str("envFile", absolutePathToEnvFile).Msg(".env overrides ENV variables!") + } +} + +// DotEnvLoad forcefully overrides ENV variables through the supplied .env file. +// +// This mechanism should only be used **locally** to easily inject (gitignored) +// secrets into your ENV. +// +// When running normally (not within tests): +// DotEnvLoad("/path/to/my.env.local", os.SetEnv) +// +// For tests (and ENV var autoreset) use t.Setenv: +// DotEnvLoad("/path/to/my.env.test.local", func(k string, v string) error { t.Setenv(k, v); return nil }) +func DotEnvLoad(absolutePathToEnvFile string, setEnvFn envSetter) error { + file, err := os.Open(absolutePathToEnvFile) + if err != nil { + return fmt.Errorf("failed to open .env file: %w", err) + } + + defer file.Close() + + envs, err := gotenv.StrictParse(file) + if err != nil { + return fmt.Errorf("failed to parse .env file: %w", err) + } + + for key, value := range envs { + if err := setEnvFn(key, value); err != nil { + return fmt.Errorf("failed to set environment variable: %w", err) + } + } + + return nil +} diff --git a/internal/config/dot_env_test.go b/internal/config/dot_env_test.go new file mode 100644 index 0000000..f4cc31f --- /dev/null +++ b/internal/config/dot_env_test.go @@ -0,0 +1,63 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestDotEnvOverride(t *testing.T) { + assert.Empty(t, os.Getenv("IS_THIS_A_TEST_ENV")) + + orgPsqlUser := os.Getenv("PSQL_USER") + + config.DotEnvTryLoad( + filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env1.local"), + func(k string, v string) error { t.Setenv(k, v); return nil }) + + assert.Equal(t, "yes", os.Getenv("IS_THIS_A_TEST_ENV")) + assert.Equal(t, "dotenv_override_psql_user", os.Getenv("PSQL_USER")) + assert.Equal(t, orgPsqlUser, os.Getenv("ORIGINAL_PSQL_USER")) + + // override works as expected? + config.DotEnvTryLoad( + filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env2.local"), + func(k string, v string) error { t.Setenv(k, v); return nil }) + + assert.Equal(t, "yes still", os.Getenv("IS_THIS_A_TEST_ENV")) + assert.NotEqual(t, "dotenv_override_psql_user", os.Getenv("PSQL_USER")) + assert.Equal(t, orgPsqlUser, os.Getenv("PSQL_USER"), "Reset to original does not work!") +} + +func TestNoopEnvNotFound(t *testing.T) { + assert.NotPanics(t, assert.PanicTestFunc(func() { + config.DotEnvTryLoad( + filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.does.not.exist"), + func(k string, v string) error { t.Setenv(k, v); return nil }, + ) + }), "does not panic on file inexistance") +} + +func TestEmptyEnv(t *testing.T) { + assert.NotPanics(t, assert.PanicTestFunc(func() { + config.DotEnvTryLoad( + filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.local.sample"), + func(k string, v string) error { t.Setenv(k, v); return nil }, + ) + }), "does not panic on file inexistance") + + assert.Empty(t, os.Getenv("EMPTY_VARIABLE_INIT"), "should be empty") +} + +func TestPanicsOnEnvMalform(t *testing.T) { + assert.Panics(t, assert.PanicTestFunc(func() { + config.DotEnvTryLoad( + filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.local.malformed"), + func(k string, v string) error { t.Setenv(k, v); return nil }, + ) + }), "does panic on file malform") +} diff --git a/internal/config/mailer_config.go b/internal/config/mailer_config.go new file mode 100644 index 0000000..ce4c580 --- /dev/null +++ b/internal/config/mailer_config.go @@ -0,0 +1,19 @@ +package config + +type MailerTransporter string + +var ( + MailerTransporterMock MailerTransporter = "mock" + MailerTransporterSMTP MailerTransporter = "SMTP" +) + +func (m MailerTransporter) String() string { + return string(m) +} + +type Mailer struct { + DefaultSender string + Send bool + WebTemplatesEmailBaseDirAbs string + Transporter string +} diff --git a/internal/config/server_config.go b/internal/config/server_config.go new file mode 100644 index 0000000..1224723 --- /dev/null +++ b/internal/config/server_config.go @@ -0,0 +1,258 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/mailer/transport" + "allaboutapps.dev/aw/go-starter/internal/push/provider" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/rs/zerolog" + "golang.org/x/text/language" +) + +type EchoServer struct { + Debug bool + ListenAddress string + HideInternalServerErrorDetails bool + BaseURL string + EnableCORSMiddleware bool + EnableLoggerMiddleware bool + EnableRecoverMiddleware bool + EnableRequestIDMiddleware bool + EnableTrailingSlashMiddleware bool + EnableSecureMiddleware bool + EnableCacheControlMiddleware bool + SecureMiddleware EchoServerSecureMiddleware + WebTemplatesViewsBaseDirAbs string +} + +type PprofServer struct { + Enable bool + EnableManagementKeyAuth bool + RuntimeBlockProfileRate int + RuntimeMutexProfileFraction int +} + +// EchoServerSecureMiddleware represents a subset of echo's secure middleware config relevant to the app server. +// https://github.com/labstack/echo/blob/master/middleware/secure.go +type EchoServerSecureMiddleware struct { + XSSProtection string + ContentTypeNosniff string + XFrameOptions string + HSTSMaxAge int + HSTSExcludeSubdomains bool + ContentSecurityPolicy string + CSPReportOnly bool + HSTSPreloadEnabled bool + ReferrerPolicy string +} + +type AuthServer struct { + AccessTokenValidity time.Duration + PasswordResetTokenValidity time.Duration + PasswordResetTokenDebounceDuration time.Duration + PasswordResetTokenReuseDuration time.Duration + DefaultUserScopes []string + LastAuthenticatedAtThreshold time.Duration + RegistrationRequiresConfirmation bool + ConfirmationTokenValidity time.Duration + ConfirmationTokenDebounceDuration time.Duration +} + +type PathsServer struct { + APIBaseDirAbs string + MntBaseDirAbs string + AppleAppSiteAssociationFile string + AndroidAssetlinksFile string +} + +type ManagementServer struct { + Secret string `json:"-"` // sensitive + ReadinessTimeout time.Duration + LivenessTimeout time.Duration + ProbeWriteablePathsAbs []string + ProbeWriteableTouchfile string + EnableMetrics bool +} + +type FrontendServer struct { + BaseURL string + PasswordResetEndpoint string +} + +type LoggerServer struct { + Level zerolog.Level + RequestLevel zerolog.Level + LogRequestBody bool + LogRequestHeader bool + LogRequestQuery bool + LogResponseBody bool + LogResponseHeader bool + LogCaller bool + PrettyPrintConsole bool +} + +type I18n struct { + DefaultLanguage language.Tag + BundleDirAbs string +} + +type Server struct { + Database Database + Echo EchoServer + Pprof PprofServer + Paths PathsServer + Auth AuthServer + Management ManagementServer + Mailer Mailer + SMTP transport.SMTPMailTransportConfig + Frontend FrontendServer + Logger LoggerServer + Push PushService + FCMConfig provider.FCMConfig + I18n I18n +} + +// DefaultServiceConfigFromEnv returns the server config as parsed from environment variables +// and their respective defaults defined below. +// We don't expect that ENV_VARs change while we are running our application or our tests +// (and it would be a bad thing to do anyways with parallel testing). +// Do NOT use os.Setenv / os.Unsetenv in tests utilizing DefaultServiceConfigFromEnv()! +func DefaultServiceConfigFromEnv() Server { + // An `.env.local` file in your project root can override the currently set ENV variables. + // + // We never automatically apply `.env.local` when running "go test" as these ENV variables + // may be sensitive (e.g. secrets to external APIs) and applying them modifies the process + // global "os.Env" state (it should be applied via t.SetEnv instead). + // + // If you need dotenv ENV variables available in a test, do that explicitly within that + // test before executing DefaultServiceConfigFromEnv (or test.WithTestServer). + // See /internal/test/helper_dot_env.go: test.DotEnvLoadLocalOrSkipTest(t) + if !testing.Testing() { + DotEnvTryLoad(filepath.Join(util.GetProjectRootDir(), ".env.local"), os.Setenv) + } + + return Server{ + Database: Database{ + Host: util.GetEnv("PGHOST", "postgres"), + Port: util.GetEnvAsInt("PGPORT", 5432), + Database: util.GetEnv("PGDATABASE", "development"), + Username: util.GetEnv("PGUSER", "dbuser"), + Password: util.GetEnv("PGPASSWORD", ""), + AdditionalParams: map[string]string{ + "sslmode": util.GetEnv("PGSSLMODE", "disable"), + }, + MaxOpenConns: util.GetEnvAsInt("DB_MAX_OPEN_CONNS", runtime.NumCPU()*2), + MaxIdleConns: util.GetEnvAsInt("DB_MAX_IDLE_CONNS", 1), + ConnMaxLifetime: time.Second * time.Duration(util.GetEnvAsInt("DB_CONN_MAX_LIFETIME_SEC", 60)), + }, + Echo: EchoServer{ + Debug: util.GetEnvAsBool("SERVER_ECHO_DEBUG", false), + ListenAddress: util.GetEnv("SERVER_ECHO_LISTEN_ADDRESS", ":8080"), + HideInternalServerErrorDetails: util.GetEnvAsBool("SERVER_ECHO_HIDE_INTERNAL_SERVER_ERROR_DETAILS", true), + BaseURL: util.GetEnv("SERVER_ECHO_BASE_URL", "http://localhost:8080"), + EnableCORSMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_CORS_MIDDLEWARE", true), + EnableLoggerMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_LOGGER_MIDDLEWARE", true), + EnableRecoverMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_RECOVER_MIDDLEWARE", true), + EnableRequestIDMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_REQUEST_ID_MIDDLEWARE", true), + EnableTrailingSlashMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_TRAILING_SLASH_MIDDLEWARE", true), + EnableSecureMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_SECURE_MIDDLEWARE", true), + EnableCacheControlMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_CACHE_CONTROL_MIDDLEWARE", true), + // see https://echo.labstack.com/middleware/secure + // see https://github.com/labstack/echo/blob/master/middleware/secure.go + SecureMiddleware: EchoServerSecureMiddleware{ + XSSProtection: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_XSS_PROTECTION", "1; mode=block"), + ContentTypeNosniff: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_CONTENT_TYPE_NOSNIFF", "nosniff"), + XFrameOptions: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_X_FRAME_OPTIONS", "SAMEORIGIN"), + HSTSMaxAge: util.GetEnvAsInt("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_MAX_AGE", 0), + HSTSExcludeSubdomains: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_EXCLUDE_SUBDOMAINS", false), + ContentSecurityPolicy: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_CONTENT_SECURITY_POLICY", ""), + CSPReportOnly: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_CSP_REPORT_ONLY", false), + HSTSPreloadEnabled: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_PRELOAD_ENABLED", false), + ReferrerPolicy: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_REFERRER_POLICY", ""), + }, + WebTemplatesViewsBaseDirAbs: util.GetEnv("SERVER_ECHO_WEB_TEMPLATES_VIEWS_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/templates/views")), + }, + Pprof: PprofServer{ + // https://golang.org/pkg/net/http/pprof/ + Enable: util.GetEnvAsBool("SERVER_PPROF_ENABLE", false), + EnableManagementKeyAuth: util.GetEnvAsBool("SERVER_PPROF_ENABLE_MANAGEMENT_KEY_AUTH", true), + RuntimeBlockProfileRate: util.GetEnvAsInt("SERVER_PPROF_RUNTIME_BLOCK_PROFILE_RATE", 0), + RuntimeMutexProfileFraction: util.GetEnvAsInt("SERVER_PPROF_RUNTIME_MUTEX_PROFILE_FRACTION", 0), + }, + Paths: PathsServer{ + // Please ALWAYS work with ABSOLUTE (ABS) paths from ENV_VARS (however you may resolve a project-relative to absolute for the default value) + APIBaseDirAbs: util.GetEnv("SERVER_PATHS_API_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/api")), // /app/api (swagger.yml) + MntBaseDirAbs: util.GetEnv("SERVER_PATHS_MNT_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/assets/mnt")), // /app/assets/mnt (user-generated content) + AppleAppSiteAssociationFile: util.GetEnv("SERVER_PATHS_APPLE_APP_SITE_ASSOCIATION_FILE", ""), + AndroidAssetlinksFile: util.GetEnv("SERVER_PATHS_ANDROID_ASSETLINKS_FILE", ""), + }, + Auth: AuthServer{ + AccessTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_ACCESS_TOKEN_VALIDITY", 86400)), + PasswordResetTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_VALIDITY", 900)), + PasswordResetTokenDebounceDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_DEBOUNCE_DURATION_SECONDS", 60)), + PasswordResetTokenReuseDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_REUSE_DURATION_SECONDS", 0)), + DefaultUserScopes: util.GetEnvAsStringArr("SERVER_AUTH_DEFAULT_USER_SCOPES", []string{"app"}), + LastAuthenticatedAtThreshold: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_LAST_AUTHENTICATED_AT_THRESHOLD", 900)), + RegistrationRequiresConfirmation: util.GetEnvAsBool("SERVER_AUTH_REGISTRATION_REQUIRES_CONFIRMATION", false), + ConfirmationTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_CONFIRMATION_TOKEN_VALIDITY_SECONDS", 86400)), + ConfirmationTokenDebounceDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_CONFIRMATION_TOKEN_DEBOUNCE_DURATION_SECONDS", 60)), + }, + Management: ManagementServer{ + Secret: util.GetMgmtSecret("SERVER_MANAGEMENT_SECRET"), + ReadinessTimeout: time.Second * time.Duration(util.GetEnvAsInt("SERVER_MANAGEMENT_READINESS_TIMEOUT_SEC", 4)), + LivenessTimeout: time.Second * time.Duration(util.GetEnvAsInt("SERVER_MANAGEMENT_LIVENESS_TIMEOUT_SEC", 9)), + ProbeWriteablePathsAbs: util.GetEnvAsStringArr("SERVER_MANAGEMENT_PROBE_WRITEABLE_PATHS_ABS", []string{ + filepath.Join(util.GetProjectRootDir(), "/assets/mnt")}, ","), + ProbeWriteableTouchfile: util.GetEnv("SERVER_MANAGEMENT_PROBE_WRITEABLE_TOUCHFILE", ".healthy"), + EnableMetrics: util.GetEnvAsBool("SERVER_MANAGEMENT_ENABLE_METRICS", false), + }, + Mailer: Mailer{ + DefaultSender: util.GetEnv("SERVER_MAILER_DEFAULT_SENDER", "go-starter@example.com"), + Send: util.GetEnvAsBool("SERVER_MAILER_SEND", true), + WebTemplatesEmailBaseDirAbs: util.GetEnv("SERVER_MAILER_WEB_TEMPLATES_EMAIL_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/templates/email")), // /app/web/templates/email + Transporter: util.GetEnvEnum("SERVER_MAILER_TRANSPORTER", MailerTransporterMock.String(), []string{MailerTransporterSMTP.String(), MailerTransporterMock.String()}), + }, + SMTP: transport.SMTPMailTransportConfig{ + Host: util.GetEnv("SERVER_SMTP_HOST", "mailhog"), + Port: util.GetEnvAsInt("SERVER_SMTP_PORT", 1025), + Username: util.GetEnv("SERVER_SMTP_USERNAME", ""), + Password: util.GetEnv("SERVER_SMTP_PASSWORD", ""), + AuthType: transport.SMTPAuthTypeFromString(util.GetEnv("SERVER_SMTP_AUTH_TYPE", transport.SMTPAuthTypeNone.String())), + Encryption: transport.SMTPEncryption(util.GetEnvEnum("SERVER_SMTP_ENCRYPTION", transport.SMTPEncryptionNone.String(), []string{transport.SMTPEncryptionNone.String(), transport.SMTPEncryptionTLS.String(), transport.SMTPEncryptionStartTLS.String()})), + TLSConfig: nil, + }, + Frontend: FrontendServer{ + BaseURL: util.GetEnv("SERVER_FRONTEND_BASE_URL", "http://localhost:3000"), + PasswordResetEndpoint: util.GetEnv("SERVER_FRONTEND_PASSWORD_RESET_ENDPOINT", "/set-new-password"), + }, + Logger: LoggerServer{ + Level: util.LogLevelFromString(util.GetEnv("SERVER_LOGGER_LEVEL", zerolog.DebugLevel.String())), + RequestLevel: util.LogLevelFromString(util.GetEnv("SERVER_LOGGER_REQUEST_LEVEL", zerolog.DebugLevel.String())), + LogRequestBody: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_BODY", false), + LogRequestHeader: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_HEADER", false), + LogRequestQuery: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_QUERY", false), + LogResponseBody: util.GetEnvAsBool("SERVER_LOGGER_LOG_RESPONSE_BODY", false), + LogResponseHeader: util.GetEnvAsBool("SERVER_LOGGER_LOG_RESPONSE_HEADER", false), + LogCaller: util.GetEnvAsBool("SERVER_LOGGER_LOG_CALLER", false), + PrettyPrintConsole: util.GetEnvAsBool("SERVER_LOGGER_PRETTY_PRINT_CONSOLE", false), + }, + Push: PushService{ + UseFCMProvider: util.GetEnvAsBool("SERVER_PUSH_USE_FCM", false), + UseMockProvider: util.GetEnvAsBool("SERVER_PUSH_USE_MOCK", true), + }, + FCMConfig: provider.FCMConfig{ + GoogleApplicationCredentials: util.GetEnv("GOOGLE_APPLICATION_CREDENTIALS", ""), + ProjectID: util.GetEnv("SERVER_FCM_PROJECT_ID", "no-fcm-project-id-set"), + ValidateOnly: util.GetEnvAsBool("SERVER_FCM_VALIDATE_ONLY", true), + }, + I18n: I18n{ + DefaultLanguage: util.GetEnvAsLanguageTag("SERVER_I18N_DEFAULT_LANGUAGE", language.English), + BundleDirAbs: util.GetEnv("SERVER_I18N_BUNDLE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/i18n")), // /app/web/i18n + }, + } +} diff --git a/internal/config/server_config_test.go b/internal/config/server_config_test.go new file mode 100644 index 0000000..9ef95a1 --- /dev/null +++ b/internal/config/server_config_test.go @@ -0,0 +1,17 @@ +package config_test + +import ( + "encoding/json" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" +) + +func TestPrintServiceEnv(t *testing.T) { + config := config.DefaultServiceConfigFromEnv() + _, err := json.MarshalIndent(config, "", " ") + + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/service_config.go b/internal/config/service_config.go new file mode 100644 index 0000000..9d6a9e0 --- /dev/null +++ b/internal/config/service_config.go @@ -0,0 +1,6 @@ +package config + +type PushService struct { + UseFCMProvider bool + UseMockProvider bool +} diff --git a/internal/config/testdata/.env.local.malformed b/internal/config/testdata/.env.local.malformed new file mode 100644 index 0000000..58e6f9e --- /dev/null +++ b/internal/config/testdata/.env.local.malformed @@ -0,0 +1 @@ +bla bla \ No newline at end of file diff --git a/internal/config/testdata/.env.local.sample b/internal/config/testdata/.env.local.sample new file mode 100644 index 0000000..0135880 --- /dev/null +++ b/internal/config/testdata/.env.local.sample @@ -0,0 +1,2 @@ +# empty value should be ok +EMPTY_VARIABLE_INIT= \ No newline at end of file diff --git a/internal/config/testdata/.env1.local b/internal/config/testdata/.env1.local new file mode 100644 index 0000000..2d828eb --- /dev/null +++ b/internal/config/testdata/.env1.local @@ -0,0 +1,3 @@ +IS_THIS_A_TEST_ENV="yes" +ORIGINAL_PSQL_USER="${PSQL_USER}" +PSQL_USER="dotenv_override_psql_user" diff --git a/internal/config/testdata/.env2.local b/internal/config/testdata/.env2.local new file mode 100644 index 0000000..7552139 --- /dev/null +++ b/internal/config/testdata/.env2.local @@ -0,0 +1,2 @@ +IS_THIS_A_TEST_ENV="yes still" +PSQL_USER="${ORIGINAL_PSQL_USER}" # reset back to proper env \ No newline at end of file diff --git a/internal/data/dto/push.go b/internal/data/dto/push.go new file mode 100644 index 0000000..408c484 --- /dev/null +++ b/internal/data/dto/push.go @@ -0,0 +1,10 @@ +package dto + +import "github.com/aarondl/null/v8" + +type UpdatePushTokenRequest struct { + User User + Token string + Provider string + ExistingToken null.String +} diff --git a/internal/data/dto/users.go b/internal/data/dto/users.go new file mode 100644 index 0000000..89358b1 --- /dev/null +++ b/internal/data/dto/users.go @@ -0,0 +1,157 @@ +package dto + +import ( + "time" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/null/v8" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/go-openapi/swag" +) + +type User struct { + ID string + Username null.String + PasswordHash null.String + IsActive bool + Scopes []string + LastAuthenticatedAt null.Time + UpdatedAt time.Time + Profile *AppUserProfile +} + +func (u User) LastUpdatedAt() time.Time { + if u.Profile != nil && u.Profile.UpdatedAt.After(u.UpdatedAt) { + return u.Profile.UpdatedAt + } + + return u.UpdatedAt +} + +func (u User) Ptr() *User { + return &u +} + +func (u User) ToTypes() *types.GetUserInfoResponse { + return &types.GetUserInfoResponse{ + Sub: swag.String(u.ID), + UpdatedAt: swag.Int64(u.LastUpdatedAt().Unix()), + Email: strfmt.Email(u.Username.String), + Scopes: u.Scopes, + } +} + +func (u User) ToModels() *models.User { + return &models.User{ + ID: u.ID, + Username: u.Username, + Password: u.PasswordHash, + IsActive: u.IsActive, + Scopes: u.Scopes, + LastAuthenticatedAt: u.LastAuthenticatedAt, + } +} + +type AppUserProfile struct { + UserID string + LegalAcceptedAt null.Time + UpdatedAt time.Time +} + +func (aup AppUserProfile) Ptr() *AppUserProfile { + return &aup +} + +type UpdatePasswordRequest struct { + User User + CurrentPassword string + SkipCurrentPasswordVerification bool + NewPassword string +} + +type RegisterResult struct { + RequiresConfirmation bool + ConfirmationToken null.String +} + +type LoginResult struct { + UserID string + AccessToken string + ExpiresIn int64 + RefreshToken string + TokenType string +} + +func (l LoginResult) ToTypes() *types.PostLoginResponse { + return &types.PostLoginResponse{ + AccessToken: conv.UUID4(strfmt.UUID4(l.AccessToken)), + RefreshToken: conv.UUID4(strfmt.UUID4(l.RefreshToken)), + ExpiresIn: swag.Int64(l.ExpiresIn), + TokenType: swag.String(l.TokenType), + } +} + +type ResetPasswordRequest struct { + ResetToken string + NewPassword string +} + +type Username struct { + val string +} + +func NewUsername(val string) Username { + return Username{val: val} +} + +func (u Username) String() string { + return util.ToUsernameFormat(u.val) +} + +type InitPasswordResetRequest struct { + Username Username +} + +type InitPasswordResetResult struct { + ResetToken null.String +} + +type LoginRequest struct { + Username Username + Password string +} + +type LogoutRequest struct { + AccessToken string + RefreshToken null.String +} + +type AuthenticateUserRequest struct { + User User + InvalidateExistingTokens bool +} + +type RefreshRequest struct { + RefreshToken string +} + +type RegisterRequest struct { + Username Username + Password string +} + +type CompleteRegisterRequest struct { + ConfirmationToken string +} + +type DeleteUserAccountRequest struct { + User User + CurrentPassword string +} + +type ConfirmatioNotificationPayload struct { + ConfirmationLink string +} diff --git a/internal/data/fixtures/fixtures.go b/internal/data/fixtures/fixtures.go new file mode 100644 index 0000000..40b65a5 --- /dev/null +++ b/internal/data/fixtures/fixtures.go @@ -0,0 +1,36 @@ +package fixtures + +import ( + "context" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/sqlboiler/v4/boil" +) + +// Live Service fixtures to be applied by manually running the CLI "app db seed" +// Note that these fixtures are not available while testing +// see the separate internal/test/fixtures.go file + +type Upsertable interface { + Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...models.UpsertOptionFunc) error +} + +// Mind the declaration order! The fields get upserted exactly in the order they are declared. +type FixtureMap struct{} + +func Fixtures() FixtureMap { + return FixtureMap{} +} + +func Upserts() []Upsertable { + fix := Fixtures() + upsertableIfc := (*Upsertable)(nil) + upserts, err := util.GetFieldsImplementing(&fix, upsertableIfc) + if err != nil { + panic(fmt.Errorf("failed to get upsertable fixture fields: %w", err)) + } + + return upserts +} diff --git a/internal/data/fixtures/fixtures_test.go b/internal/data/fixtures/fixtures_test.go new file mode 100644 index 0000000..3c731b1 --- /dev/null +++ b/internal/data/fixtures/fixtures_test.go @@ -0,0 +1,18 @@ +package fixtures_test + +import ( + "testing" + + data "allaboutapps.dev/aw/go-starter/internal/data/fixtures" + "allaboutapps.dev/aw/go-starter/internal/models" + "github.com/stretchr/testify/assert" +) + +func TestUpsertableInterface(t *testing.T) { + var user any = &models.AppUserProfile{ + UserID: "62b13d29-5c4e-420e-b991-a631d3938776", + } + + _, ok := user.(data.Upsertable) + assert.True(t, ok, "AppUserProfile should implement the Upsertable interface") +} diff --git a/internal/data/local/service.go b/internal/data/local/service.go new file mode 100644 index 0000000..e5ef5b2 --- /dev/null +++ b/internal/data/local/service.go @@ -0,0 +1,22 @@ +package local + +import ( + "database/sql" + + "allaboutapps.dev/aw/go-starter/internal/config" + "github.com/dropbox/godropbox/time2" +) + +type Service struct { + config config.Server + db *sql.DB + clock time2.Clock +} + +func NewService(config config.Server, db *sql.DB, clock time2.Clock) *Service { + return &Service{ + config: config, + db: db, + clock: clock, + } +} diff --git a/internal/data/local/service_push.go b/internal/data/local/service_push.go new file mode 100644 index 0000000..b807793 --- /dev/null +++ b/internal/data/local/service_push.go @@ -0,0 +1,75 @@ +package local + +import ( + "context" + "database/sql" + "errors" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/boil" +) + +func (s *Service) UpdatePushToken(ctx context.Context, request dto.UpdatePushTokenRequest) error { + log := util.LogFromContext(ctx).With().Str("userID", request.User.ID).Logger() + + err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error { + tokenExists, err := models.PushTokens( + models.PushTokenWhere.Token.EQ(request.Token), + ).Exists(ctx, exec) + if err != nil { + log.Err(err).Msg("Failed to check if token exists") + return err + } + + if tokenExists { + log.Debug().Msg("Token already exists") + return httperrors.ErrConflictPushToken + } + + newToken := models.PushToken{ + UserID: request.User.ID, + Token: request.Token, + Provider: request.Provider, + } + + if err := newToken.Insert(ctx, s.db, boil.Infer()); err != nil { + log.Err(err).Msg("Failed to insert new token") + return err + } + + if request.ExistingToken.IsZero() { + return nil + } + + existingToken, err := models.PushTokens( + models.PushTokenWhere.Token.EQ(request.ExistingToken.String), + models.PushTokenWhere.UserID.EQ(request.User.ID), + ).One(ctx, exec) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.Debug().Msg("Existing token not found") + return httperrors.ErrNotFoundOldPushToken + } + + log.Err(err).Msg("Failed to find existing token") + return err + } + + if _, err := existingToken.Delete(ctx, exec); err != nil { + log.Err(err).Msg("Failed to delete existing token") + return err + } + + return nil + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to update push token") + return err + } + + return nil +} diff --git a/internal/data/mapper/users.go b/internal/data/mapper/users.go new file mode 100644 index 0000000..ecc2837 --- /dev/null +++ b/internal/data/mapper/users.go @@ -0,0 +1,32 @@ +package mapper + +import ( + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/models" +) + +func LocalAppUserProfileToDTO(appUserProfile *models.AppUserProfile) dto.AppUserProfile { + return dto.AppUserProfile{ + UserID: appUserProfile.UserID, + LegalAcceptedAt: appUserProfile.LegalAcceptedAt, + UpdatedAt: appUserProfile.UpdatedAt, + } +} + +func LocalUserToDTO(user *models.User) dto.User { + result := dto.User{ + ID: user.ID, + Username: user.Username, + IsActive: user.IsActive, + Scopes: user.Scopes, + LastAuthenticatedAt: user.LastAuthenticatedAt, + UpdatedAt: user.UpdatedAt, + PasswordHash: user.Password, + } + + if user.R != nil && user.R.AppUserProfile != nil { + result.Profile = LocalAppUserProfileToDTO(user.R.AppUserProfile).Ptr() + } + + return result +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..61e2aa3 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,211 @@ +package i18n + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/config" + "github.com/BurntSushi/toml" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/rs/zerolog/log" + "golang.org/x/text/language" +) + +// i18n, we expect the following: +// Your translation toml files should live within /web/i18n and are named according to their supported locale e.g. en.toml, de.toml or en-uk.toml, en-us.toml. +// +// All translation files should hold the same keys (there are no unique keys on a single bundle locale, apart from pluralization). +// +// The Service object is created and owned by the api.Server (s.I18n), you typically don't want to create your own object. +// +// Pluralization obeys to CLDR rules (https://cldr.unicode.org/index/cldr-spec/plural-rules). +// Some keywords are reserved for CLDR behaviour, templating and documentation: id, description, hash, leftdelim, rightdelim, zero, one, two, few, many, other +// See + +// Service is your convenience object to call Translate/TranslatePlural and match languages according to your loaded translation bundle and its supported languages/locales. +type Service struct { + bundle *i18n.Bundle + matcher language.Matcher +} + +// Data should be used to pass your template data +type Data map[string]string + +// New returns a new Service struct holding bundle and matcher with the settings of the given config +// +// Note that Service is typically created and owned by the api.Server (use it via s.I18n) +func New(config config.I18n) (*Service, error) { + bundle := i18n.NewBundle(config.DefaultLanguage) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + + // Load all translation files in each language... + files, err := os.ReadDir(config.BundleDirAbs) + if err != nil { + log.Err(err).Str("dir", config.BundleDirAbs).Msg("Failed to read i18n bundle directory") + return nil, fmt.Errorf("failed to read i18n bundle directory: %w", err) + } + + for _, file := range files { + if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") { + continue + } + + // bundle.LoadMessageFile automatically guesses the language.Tag based on the filenames it encounters + _, err := bundle.LoadMessageFile(filepath.Join(config.BundleDirAbs, file.Name())) + if err != nil { + log.Err(err).Str("file", file.Name()).Msg("Failed to load i18n message file") + return nil, fmt.Errorf("failed to load i18n message file: %w", err) + } + } + + tags := bundle.LanguageTags() + + for tagIndex, tag := range tags { + // Undetermined languages are disallowed in our bundle. + if tag == language.Und { + err := fmt.Errorf("undetermined language at index %v in i18n message bundle: %v", tagIndex, tags) + log.Err(err).Int("index", tagIndex).Str("tags", fmt.Sprintf("%v", tags)).Msg("Invalid i18n message bundle or default language.") + return nil, fmt.Errorf("invalid i18n message bundle or default language: %w", err) + } + } + + return &Service{ + bundle: bundle, + matcher: language.NewMatcher(tags), + }, nil +} + +// Translate your key into a localized string. +// +// Translate makes a lookup for the key in the current bundle with the specified language. +// If a language translation is not available the default language will be used. +// Additional data for templated strings can be passed as key value pairs with by passing an optional data map. +// +// Translate will not fail if a template value is missing "" will be inserted instead. +// Translate will also not fail if the key is not present. "{{key}}" will be returned instead. +func (m *Service) Translate(key string, lang language.Tag, data ...Data) string { + msg, err := m.TranslateMaybe(key, lang, data...) + + if err != nil { + log.Debug().Err(err).Str("key", key).Str("lang", lang.String()).Msg("Failed to translate") + return key + } + + return msg +} + +// TranslateMaybe has the same sematics as Translate with the following exceptions: +// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string! +// +// This method may be useful for conditional translation rendering (if key is available, use that, else...). +func (m *Service) TranslateMaybe(key string, lang language.Tag, data ...Data) (string, error) { + localizeConfig := &i18n.LocalizeConfig{ + MessageID: key, + } + + if len(data) > 0 { + localizeConfig.TemplateData = data[0] + } + + return m.translateConfigurable(lang, localizeConfig) +} + +// TranslatePlural translates a pluralized cldrKey into a localized string. +// +// TranslatePlural makes a lookup for the cldrKey (a base key holding CLDR keys like "one" and "other") in the current bundle with the specified language. +// This function should be used to conditionally show the pluralized form, controlled by the count param and according to the CLDR rules. +// +// Note that English and German only support .one and .other CLDR plural rules. +// See https://cldr.unicode.org/index/cldr-spec/plural-rules and https://www.unicode.org/cldr/cldr-aux/charts/28/supplemental/language_plural_rules.html +// +// If a language translation is not available the default language will be used. +// Additional data for templated strings can be passed as key value pairs with by passing an optional data map. +// The count param is automatically injected into this data map as stringified {{.Count}} and may be overwritten. +// +// TranslatePlural will not fail if a template value is missing "" will be inserted instead. +// TranslatePlural will also not fail if the cldrKey is not present. "{{cldrKey}} (count={{count}})" will be returned instead. +func (m *Service) TranslatePlural(cldrKey string, count interface{}, lang language.Tag, data ...Data) string { + msg, err := m.TranslatePluralMaybe(cldrKey, count, lang, data...) + if err != nil { + log.Debug().Err(err).Str("count", fmt.Sprintf("%v", count)).Str("cldrKey", cldrKey).Str("lang", lang.String()).Msg("Failed to translate plural") + return fmt.Sprintf("%s (count=%v)", cldrKey, count) + } + + return msg +} + +// TranslatePluralMaybe uses the same sematics as TranslatePlural with the following exceptions: +// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string! +// +// This method may be useful for conditional plural translation rendering (if key is available, use that, else...). +func (m *Service) TranslatePluralMaybe(cldrKey string, count interface{}, lang language.Tag, data ...Data) (string, error) { + localizeConfig := &i18n.LocalizeConfig{ + MessageID: cldrKey, + PluralCount: count, + } + + // We inject Count by default into our template data (for rare usecases you may overwrite it) + templateData := make(Data) + templateData["Count"] = fmt.Sprintf("%v", count) + + // If optional data was provided, merge them into the templateData map + if len(data) > 0 { + for k, v := range data[0] { + templateData[k] = v + } + } + + localizeConfig.TemplateData = templateData + + return m.translateConfigurable(lang, localizeConfig) +} + +// ParseAcceptLanguage takes the value of the Accept-Language header and returns +// the best matched language using the matcher. +func (m *Service) ParseAcceptLanguage(lang string) language.Tag { + // we deliberately ignore the error returned here, as it will be nil and the matcher will simply pick the default language + // this allows us to skip any malformed Accept-Language headers without returning 500 errors to the client + // additionally, we don't really care about the q-factor weighting or confidence, the first match will be picked (with a fallback to config.DefaultLanguage) + tags, _, err := language.ParseAcceptLanguage(lang) + if err != nil { + log.Err(err).Str("lang", lang).Msg("Failed to parse accept language") + } + matchedTag, _, _ := m.matcher.Match(tags...) + + return matchedTag +} + +// ParseLang parses the string as language tag and returns +// the best matched language using the matcher. +func (m *Service) ParseLang(lang string) language.Tag { + t, err := language.Parse(lang) + if err != nil { + log.Err(err).Str("lang", lang).Msg("Failed to parse language") + } + + matchedTag, _, _ := m.matcher.Match(t) + + return matchedTag +} + +// Tags returns the parsed and priority ordered []language.Tag (your config.DefaultLanguage will be on position 0) +func (m *Service) Tags() []language.Tag { + return m.bundle.LanguageTags() +} + +// translateConfigurable is used internally for fully configurable translations according to our configured language precedence semantics (new Localizer per call). +func (m *Service) translateConfigurable(lang language.Tag, localizeConfig *i18n.LocalizeConfig) (string, error) { + // We benchmarked precaching all known []i18n.NewLocalizer during initialization, + // but it doesn't make a significant difference even with 10000 concurrent * 8 .Translate calls. + // Thus we take the easy route and initialize a new localizer with each .Translate or .TranslatePlural call. + localizer := i18n.NewLocalizer(m.bundle, lang.String()) + + msg, err := localizer.Localize(localizeConfig) + if err != nil { + return msg, fmt.Errorf("failed to localize: %w", err) + } + + return msg, nil +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..8bad791 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,589 @@ +package i18n_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/i18n" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" +) + +func TestServerProvidedI18n(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + // uses /app/web/i18n by default + // expect all i18n files were loaded and the defaultLanguage matches the FIRST priority Tag. + assert.Equal(t, s.Config.I18n.DefaultLanguage, s.I18n.Tags()[0]) + + // expect all i18ns were loaded... + files, err := os.ReadDir(s.Config.I18n.BundleDirAbs) + require.NoError(t, err) + + msgFilesCount := 0 + + for _, file := range files { + if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") { + continue + } + + msgFilesCount++ + } + + if msgFilesCount == 0 { + // no i18n bundles were available, as the defaultLanguage is a tag itself, check for len 1 + assert.Len(t, s.I18n.Tags(), 1) + } else { + assert.Len(t, s.I18n.Tags(), msgFilesCount) + } + + msg := s.I18n.Translate("this.key.will.never.exist", s.Config.I18n.DefaultLanguage) + assert.Equal(t, "this.key.will.never.exist", msg) + }) +} + +// Note that all following tests use a special message directory within /internal/i18n/testdata. +// We do this to ensure we don't depend on your project specific i18n bundle/configuration, +// that you would typically store within /web/i18n. + +func TestI18n(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + require.NoError(t, err) + + assert.Equal(t, language.English, srv.Tags()[0]) + assert.Equal(t, language.German, srv.Tags()[1]) + assert.Len(t, srv.Tags(), 2) + + msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Guten Tag Hans", msg) + + msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Welcome Hans", msg) + + msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Welcome Hans", msg) + + msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"}) + assert.Equal(t, "Welcome Franz", msg) + + msg = srv.Translate("Test.Welcome", language.English) + assert.Equal(t, "Welcome ", msg) + + msg = srv.Translate("Test.Body", language.German) + assert.Equal(t, "Das ist ein Test", msg) + + msg = srv.Translate("Test.Body", language.English) + assert.Equal(t, "This is a test", msg) + + msg = srv.Translate("Test.Body", language.Spanish) + assert.Equal(t, "This is a test", msg) + + msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.English) + assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg) + + msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.German) + assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg) + + msg = srv.Translate("Test.String.DE.only", language.English) + assert.Equal(t, "Test.String.DE.only", msg) + + msg = srv.Translate("Test.String.DE.only", language.German) + assert.Equal(t, "This key only exists in DE", msg) + + msg, err = srv.TranslateMaybe("Test.String.DE.only", language.English) + require.Error(t, err) + assert.Empty(t, msg) // no fallback! + + msg = srv.Translate("Test.String.EN.only", language.English) + assert.Equal(t, "This key only exists in EN", msg) + + msg, err = srv.TranslateMaybe("Test.String.EN.only", language.German) + require.Error(t, err) + assert.Equal(t, "This key only exists in EN", msg) // fallback (but error) + + msg = srv.Translate("Test.String.EN.only", language.German) + assert.Equal(t, "Test.String.EN.only", msg) + + msg = srv.Translate("", language.German) // empty key + assert.Empty(t, msg) + + msg, err = srv.TranslateMaybe("", language.German) // empty key + require.Error(t, err) + assert.Empty(t, msg) + + // ensure language subvariants are supported + deAt := srv.ParseLang("de-AT") + assert.NotEqual(t, language.German, deAt) + msg = srv.Translate("Test.Body", deAt) + assert.Equal(t, "Das ist ein Test", msg) +} + +func TestI18nConcurrentUsage(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + require.NoError(t, err) + + wg := sync.WaitGroup{} + wg.Add(100) + + for i := 0; i < 100; i++ { + go func(index int) { + msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": strconv.Itoa(index)}) + assert.Equal(t, fmt.Sprintf("Guten Tag %v", index), msg) + + msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": strconv.Itoa(index)}) + assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg) + + msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": strconv.Itoa(index)}) + assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg) + + msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"}) + assert.Equal(t, "Welcome Franz", msg) + + msg = srv.Translate("Test.Welcome", language.English) + assert.Equal(t, "Welcome ", msg) + + msg = srv.Translate("Test.Body", language.German) + assert.Equal(t, "Das ist ein Test", msg) + + msg = srv.Translate("Test.Body", language.English) + assert.Equal(t, "This is a test", msg) + + msg = srv.Translate("Test.Body", language.Spanish) + assert.Equal(t, "This is a test", msg) + wg.Done() + }(i) + } + + wg.Wait() +} + +func TestI18nOtherDefault(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.German, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + require.NoError(t, err) + + assert.Equal(t, language.German, srv.Tags()[0]) + assert.Equal(t, language.English, srv.Tags()[1]) + assert.Len(t, srv.Tags(), 2) +} + +func TestI18nInexistantDefault(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.Italian, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + require.NoError(t, err) + + assert.Equal(t, language.Italian, srv.Tags()[0]) + assert.Equal(t, language.German, srv.Tags()[1]) + assert.Equal(t, language.English, srv.Tags()[2]) + assert.Len(t, srv.Tags(), 3) +} + +func TestI18nEmpty(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.Italian, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-empty"), + }) + require.NoError(t, err) + assert.Len(t, srv.Tags(), 1) // the DefaultLanguage should still be set! + assert.Equal(t, language.Italian, srv.Tags()[0]) + + tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3") + assert.Equal(t, language.Italian, tag) + + msg := srv.Translate("no.test.key.exists", tag) + assert.Equal(t, "no.test.key.exists", msg) + + msg, err = srv.TranslateMaybe("no.test.key.exists", tag) + require.Error(t, err) + assert.Empty(t, msg) + + msg = srv.Translate("no.test.key.exists", language.Ukrainian) + assert.Equal(t, "no.test.key.exists", msg) + + msg, err = srv.TranslateMaybe("no.test.key.exists", language.Ukrainian) + require.Error(t, err) + assert.Empty(t, msg) +} + +func TestI18nSpecialized(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.AmericanEnglish, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-specialized"), + }) + + require.NoError(t, err) + assert.Len(t, srv.Tags(), 4) // specialized subvariant is default + assert.Equal(t, language.AmericanEnglish, srv.Tags()[0]) + + msg := srv.Translate("test.punchline", language.AmericanEnglish) + assert.Equal(t, "I can has HUMOR?", msg) + + msg = srv.Translate("test.punchline", language.BritishEnglish) + assert.Equal(t, "I can has HUMOUR?", msg) + + msg = srv.Translate("test.punchline", language.English) + assert.Equal(t, "I can has HUMOR?", msg) // fall back to default + + msg = srv.Translate("test.punchline", language.German) + assert.Equal(t, "Habe ich Humor?", msg) // jump to parsed Austrian German + + tag := srv.ParseAcceptLanguage("de-at,en-US;q=0.7,en;q=0.3") // explicit Austrian tag + msg = srv.Translate("test.punchline", tag) + assert.Equal(t, "Koan i Humor?", msg) // jump to parsed Austrian German +} + +func TestReservedKeywordsResolve(t *testing.T) { + // "reserved" keys: + // "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other" + // see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181 + + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-reserved"), + }) + + require.NoError(t, err) + assert.Len(t, srv.Tags(), 2) + + // single reserved word (not yet mapped, requires 2 keywords at least) + msg := srv.Translate("reserved1.zero", language.English) + assert.Equal(t, "Zero", msg) + + msg = srv.Translate("reserved2.one", language.English) + assert.Equal(t, "One", msg) + + msg = srv.Translate("reserved3.two", language.English) + assert.Equal(t, "Two", msg) + + msg = srv.Translate("reserved4.few", language.English) + assert.Equal(t, "Few", msg) + + msg = srv.Translate("reserved5.many", language.English) + assert.Equal(t, "Many", msg) + + msg = srv.Translate("reserved6.other", language.English) + assert.Equal(t, "Other", msg) + + msg = srv.Translate("reserved7.id", language.English) + assert.Equal(t, "id", msg) + + msg = srv.Translate("reserved8.description", language.English) + assert.Equal(t, "Description", msg) + + // single parent is not directly resolveable + msg = srv.Translate("reserved2", language.English) + assert.Equal(t, "reserved2", msg) + + // german: single reserved word + msg = srv.Translate("reserved1.zero", language.German) + assert.Equal(t, "Null", msg) + + msg = srv.Translate("reserved2.one", language.German) + assert.Equal(t, "Eins", msg) + + msg = srv.Translate("reserved3.two", language.German) + assert.Equal(t, "Zwei", msg) + + msg = srv.Translate("reserved4.few", language.German) + assert.Equal(t, "Wenig", msg) + + msg = srv.Translate("reserved5.many", language.German) + assert.Equal(t, "Mehr", msg) + + msg = srv.Translate("reserved6.other", language.German) + assert.Equal(t, "Andere", msg) + + msg = srv.Translate("reserved7.id", language.German) + assert.Equal(t, "ID", msg) + + msg = srv.Translate("reserved8.description", language.German) + assert.Equal(t, "Beschreibung", msg) + + // Combined toml map: all reserved words + // This does not work as it's parsed as map and CLDR plural rules now apply! + // The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules + msg = srv.Translate("reservedMap.zero", language.English) + assert.Equal(t, "reservedMap.zero", msg) + + msg = srv.Translate("reservedMap.one", language.English) + assert.Equal(t, "reservedMap.one", msg) + + msg = srv.Translate("reservedMap.two", language.English) + assert.Equal(t, "reservedMap.two", msg) + + msg = srv.Translate("reservedMap.few", language.English) + assert.Equal(t, "reservedMap.few", msg) + + msg = srv.Translate("reservedMap.many", language.English) + assert.Equal(t, "reservedMap.many", msg) + + msg = srv.Translate("reservedMap.other", language.English) + assert.Equal(t, "reservedMap.other", msg) + + msg = srv.TranslatePlural("reservedMap", 0, language.English) + assert.Equal(t, "Other", msg) + + msg = srv.TranslatePlural("reservedMap", 1, language.English) + assert.Equal(t, "One", msg) + + msg = srv.TranslatePlural("reservedMap", 2, language.English) + assert.Equal(t, "Other", msg) + + msg = srv.TranslatePlural("reservedMap", "asdfasdf", language.English) + assert.Equal(t, "reservedMap (count=asdfasdf)", msg) + + // plain toml: all reserved words + // This does not work as it's parsed as map and CLDR plural rules now apply! + // The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules + msg = srv.Translate("reserved.plain.zero", language.English) + assert.Equal(t, "reserved.plain.zero", msg) + + msg = srv.Translate("reserved.plain.one", language.English) + assert.Equal(t, "reserved.plain.one", msg) + + msg = srv.Translate("reserved.plain.two", language.English) + assert.Equal(t, "reserved.plain.two", msg) + + msg = srv.Translate("reserved.plain.few", language.English) + assert.Equal(t, "reserved.plain.few", msg) + + msg = srv.Translate("reserved.plain.many", language.English) + assert.Equal(t, "reserved.plain.many", msg) + + msg = srv.Translate("reserved.plain.other", language.English) + assert.Equal(t, "reserved.plain.other", msg) + + msg = srv.Translate("reserved.plain2.id", language.English) + assert.Equal(t, "reserved.plain2.id", msg) + + msg = srv.Translate("reserved.plain2.description", language.English) + assert.Equal(t, "reserved.plain2.description", msg) + + msg = srv.Translate("reserved.plain3.id", language.English) + assert.Equal(t, "reserved.plain3.id", msg) + + msg = srv.Translate("reserved.plain3.description", language.English) + assert.Equal(t, "reserved.plain3.description", msg) + + msg = srv.Translate("reserved.plain3.test", language.English) + assert.Equal(t, "reserved.plain3.test", msg) +} + +func TestI18nPlural(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-plural"), + }) + + require.NoError(t, err) + assert.Len(t, srv.Tags(), 2) + + msg := srv.TranslatePlural("cats", 0, language.AmericanEnglish) + assert.Equal(t, "I've 0 cats.", msg) // zero is not supported for CLDR English, "I don't have a cat." is not possible! + + msg = srv.TranslatePlural("cats", 1, language.BritishEnglish) + assert.Equal(t, "I've one cat.", msg) + + msg = srv.TranslatePlural("cats", 2, language.English) + assert.Equal(t, "I've 2 cats.", msg) + + msg = srv.TranslatePlural("cats", 8, language.English) + assert.Equal(t, "I've 8 cats.", msg) + + msg = srv.TranslatePlural("cats", -1, language.English) // negative and positive scales behave the same! + assert.Equal(t, "I've one cat.", msg) + + msg = srv.TranslatePlural("cats", -2, language.English) // negative and positive scales behave the same! + assert.Equal(t, "I've -2 cats.", msg) + + msg, err = srv.TranslatePluralMaybe("cats", -2, language.English) + require.NoError(t, err) + assert.Equal(t, "I've -2 cats.", msg) + + msg = srv.TranslatePlural("cats", nil, language.English) + assert.Equal(t, "I've cats.", msg) // invalid count + + msg, err = srv.TranslatePluralMaybe("cats", nil, language.English) + require.NoError(t, err) + assert.Equal(t, "I've cats.", msg) // invalid count + + msg = srv.TranslatePlural("cats", "many", language.English) + assert.Equal(t, "cats (count=many)", msg) // internal failed to translate plural! + + // overwrite Count + msg = srv.TranslatePlural("cats", 8, language.English, i18n.Data{"Count": "too many"}) + assert.Equal(t, "I've too many cats.", msg) + + msg = srv.TranslatePlural("cats", 0, language.German) + assert.Equal(t, "Ich habe 0 Katzen.", msg) // zero is not supported for CLDR German, "Ich habe keine Katze." is not possible! + + msg = srv.TranslatePlural("cats", 1, language.German) + assert.Equal(t, "Ich habe eine Katze.", msg) + + msg = srv.TranslatePlural("cats", 2, language.German) + assert.Equal(t, "Ich habe 2 Katzen.", msg) + + msg = srv.TranslatePlural("cats", 8, language.German) + assert.Equal(t, "Ich habe 8 Katzen.", msg) + + msg = srv.TranslatePlural("cats", "viele", language.German) + assert.Equal(t, "cats (count=viele)", msg) // internal failed to translate plural! + + msg, err = srv.TranslatePluralMaybe("cats", "viele", language.German) + require.Error(t, err) + assert.Empty(t, msg) // empty string for errors! + + // overwrite Count + msg = srv.TranslatePlural("cats", 8, language.German, i18n.Data{"Count": "zu viele"}) + assert.Equal(t, "Ich habe zu viele Katzen.", msg) + + // unknown language string + tag := srv.ParseLang("xx") + assert.Equal(t, language.English, tag) + msg = srv.TranslatePlural("cats", 8, tag) + assert.Equal(t, "I've 8 cats.", msg) // fall back to English + + // invalid specialized language string + tag = srv.ParseLang("de-xx") + msg = srv.TranslatePlural("cats", 8, tag) + assert.Equal(t, "Ich habe 8 Katzen.", msg) // fall back to German + + // invalid language string + tag = srv.ParseLang("§$%/%&/(/&%/)(") + assert.Equal(t, language.English, tag) + msg = srv.TranslatePlural("cats", 8, tag) + assert.Equal(t, "I've 8 cats.", msg) // fall back to English + + // unknown + msg = srv.TranslatePlural("this.key.will.never.exist", nil, language.English) + assert.Equal(t, "this.key.will.never.exist (count=)", msg) + + msg, err = srv.TranslatePluralMaybe("this.key.will.never.exist", nil, language.English) + require.Error(t, err) + assert.Empty(t, msg) // empty string for errors! +} + +func TestI18nUndetermined(t *testing.T) { + _, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-undetermined"), + }) + + require.Error(t, err) + + unwrappedErr := errors.Unwrap(err) + require.Error(t, unwrappedErr) + assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 1 in i18n message bundle: [en und]")) +} + +func TestI18nUndeterminedDefaultLanguage(t *testing.T) { + _, err := i18n.New(config.I18n{ + DefaultLanguage: language.Und, // Undetermined is disallowed + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + + require.Error(t, err) + + unwrappedErr := errors.Unwrap(err) + require.Error(t, unwrappedErr) + assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 0 in i18n message bundle: [und de en]")) +} + +func TestI18nInvalidToml(t *testing.T) { + _, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-invalid"), + }) + + require.Error(t, err) +} + +func TestI18nInexistantFolder(t *testing.T) { + _, err := i18n.New(config.I18n{ + DefaultLanguage: language.AmericanEnglish, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n/this/folder/does/not/exist"), + }) + + require.Error(t, err) +} + +func TestParseAcceptLanguage(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + + require.NoError(t, err) + + tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3") + assert.Equal(t, language.German, tag) + + tag = srv.ParseAcceptLanguage("de-AT,en-US;q=0.7,en;q=0.3") + assert.NotEqual(t, language.German, tag) // actual: de-u-rg-atzzzz + + // unknown language header + tag = srv.ParseAcceptLanguage("xx,en-US;q=0.7,en;q=0.3") + assert.Equal(t, language.English, tag) + msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Welcome Hans", msg) + + // invalid specialized language string + tag = srv.ParseAcceptLanguage("de-xx,en-US;q=0.7,en;q=0.3") + msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Guten Tag Hans", msg) + + // invalid language header + tag = srv.ParseAcceptLanguage("§$%/%&/(/&%/)(") + assert.Equal(t, language.English, tag) + msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Welcome Hans", msg) +} + +func TestParseLanguage(t *testing.T) { + srv, err := i18n.New(config.I18n{ + DefaultLanguage: language.English, + BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"), + }) + + require.NoError(t, err) + + tag := srv.ParseLang("de") + assert.Equal(t, language.German, tag) + + // unknown language string + tag = srv.ParseLang("xx") + assert.Equal(t, language.English, tag) + msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Welcome Hans", msg) + + // invalid specialized language string + tag = srv.ParseLang("de-xx") + msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Guten Tag Hans", msg) // fall back to German + + // invalid language string + tag = srv.ParseLang("§$%/%&/(/&%/)(") + assert.Equal(t, language.English, tag) + msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"}) + assert.Equal(t, "Welcome Hans", msg) +} diff --git a/internal/i18n/testdata/README.md b/internal/i18n/testdata/README.md new file mode 100644 index 0000000..926b5a0 --- /dev/null +++ b/internal/i18n/testdata/README.md @@ -0,0 +1,3 @@ +# `internal/i18n/testdata` + +These translation toml files are not meant to be touched while doing application development, rather they are here to test the baseline functionality of the i18n package. \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-empty/.gitkeep b/internal/i18n/testdata/i18n-empty/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/i18n/testdata/i18n-invalid/en.toml b/internal/i18n/testdata/i18n-invalid/en.toml new file mode 100644 index 0000000..d9237bd --- /dev/null +++ b/internal/i18n/testdata/i18n-invalid/en.toml @@ -0,0 +1,2 @@ +# This file in invalid on purpose. +This is not toml. \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-plural/de.toml b/internal/i18n/testdata/i18n-plural/de.toml new file mode 100644 index 0000000..2a97e0c --- /dev/null +++ b/internal/i18n/testdata/i18n-plural/de.toml @@ -0,0 +1,13 @@ +# {{.Count}} is injected by default while using service.TranslatePlural + +# pluralized structured format +[cats] +zero="Ich habe keine Katze." +one="Ich habe eine Katze." +other="Ich habe {{.Count}} Katzen." + +# pluralized single line format +"dogs.zero"="Ich habe keinen Hund." +"dogs.one"="Ich habe einen Hund." +"dogs.other"="Ich habe {{.Count}} Hunde." + diff --git a/internal/i18n/testdata/i18n-plural/en.toml b/internal/i18n/testdata/i18n-plural/en.toml new file mode 100644 index 0000000..4be650b --- /dev/null +++ b/internal/i18n/testdata/i18n-plural/en.toml @@ -0,0 +1,12 @@ +# {{.Count}} is injected by default while using service.TranslatePlural + +# pluralized structured format +[cats] +zero="I don't have a cat." +one="I've one cat." +other="I've {{.Count}} cats." + +# pluralized single line format +"dogs.zero"="I don't have a dog." +"dogs.one"="I've one dog." +"dogs.other"="I've {{.Count}} dogs." diff --git a/internal/i18n/testdata/i18n-reserved/de.toml b/internal/i18n/testdata/i18n-reserved/de.toml new file mode 100644 index 0000000..e3b9c40 --- /dev/null +++ b/internal/i18n/testdata/i18n-reserved/de.toml @@ -0,0 +1,34 @@ +# "reserved" keys: +# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other" +# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181 + +"reserved1.zero"="Null" +"reserved2.one"="Eins" +"reserved3.two"="Zwei" +"reserved4.few"="Wenig" +"reserved5.many"="Mehr" +"reserved6.other"="Andere" +"reserved7.id"="ID" +"reserved8.description"="Beschreibung" + +[reservedMap] +zero="Null" +one="Eins" +two="Zwei" +few="Wenig" +many="Mehr" +other="Andere" + +"reserved.plain.zero"="Null" +"reserved.plain.one"="Eins" +"reserved.plain.two"="Zwei" +"reserved.plain.few"="Wenig" +"reserved.plain.many"="Mehr" +"reserved.plain.other"="Andere" + +"reserved.plain2.id"="ID" +"reserved.plain2.description"="Beschreibung" + +"reserved.plain3.id"="ID" +"reserved.plain3.description"="Beschreibung" +"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN? \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-reserved/en.toml b/internal/i18n/testdata/i18n-reserved/en.toml new file mode 100644 index 0000000..dbfad5e --- /dev/null +++ b/internal/i18n/testdata/i18n-reserved/en.toml @@ -0,0 +1,34 @@ +# "reserved" keys: +# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other" +# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181 + +"reserved1.zero"="Zero" +"reserved2.one"="One" +"reserved3.two"="Two" +"reserved4.few"="Few" +"reserved5.many"="Many" +"reserved6.other"="Other" +"reserved7.id"="id" +"reserved8.description"="Description" + +[reservedMap] +zero="Zero" +one="One" +two="Two" +few="Few" +many="Many" +other="Other" + +"reserved.plain.zero"="Zero" +"reserved.plain.one"="One" +"reserved.plain.two"="Two" +"reserved.plain.few"="Few" +"reserved.plain.many"="Many" +"reserved.plain.other"="Other" + +"reserved.plain2.id"="id" +"reserved.plain2.description"="Description" + +"reserved.plain3.id"="id" +"reserved.plain3.description"="Description" +"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN? \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-specialized/de-at.toml b/internal/i18n/testdata/i18n-specialized/de-at.toml new file mode 100644 index 0000000..21ed04c --- /dev/null +++ b/internal/i18n/testdata/i18n-specialized/de-at.toml @@ -0,0 +1,2 @@ +[test] +punchline="Koan i Humor?" \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-specialized/de-de.toml b/internal/i18n/testdata/i18n-specialized/de-de.toml new file mode 100644 index 0000000..823e01e --- /dev/null +++ b/internal/i18n/testdata/i18n-specialized/de-de.toml @@ -0,0 +1,2 @@ +[test] +punchline="Habe ich Humor?" \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-specialized/en-uk.toml b/internal/i18n/testdata/i18n-specialized/en-uk.toml new file mode 100644 index 0000000..0fa1292 --- /dev/null +++ b/internal/i18n/testdata/i18n-specialized/en-uk.toml @@ -0,0 +1,2 @@ +[test] +punchline="I can has HUMOUR?" \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-specialized/en-us.toml b/internal/i18n/testdata/i18n-specialized/en-us.toml new file mode 100644 index 0000000..b276b6a --- /dev/null +++ b/internal/i18n/testdata/i18n-specialized/en-us.toml @@ -0,0 +1,2 @@ +[test] +punchline="I can has HUMOR?" \ No newline at end of file diff --git a/internal/i18n/testdata/i18n-undetermined/xx.toml b/internal/i18n/testdata/i18n-undetermined/xx.toml new file mode 100644 index 0000000..7e1df1e --- /dev/null +++ b/internal/i18n/testdata/i18n-undetermined/xx.toml @@ -0,0 +1,2 @@ +[test] +language="xx" \ No newline at end of file diff --git a/internal/i18n/testdata/i18n/de.toml b/internal/i18n/testdata/i18n/de.toml new file mode 100644 index 0000000..490e5f3 --- /dev/null +++ b/internal/i18n/testdata/i18n/de.toml @@ -0,0 +1,4 @@ +[Test] +Body = "Das ist ein Test" +Welcome = "Guten Tag {{.Name}}" +"String.DE.only" = "This key only exists in DE" \ No newline at end of file diff --git a/internal/i18n/testdata/i18n/en.toml b/internal/i18n/testdata/i18n/en.toml new file mode 100644 index 0000000..3249e2f --- /dev/null +++ b/internal/i18n/testdata/i18n/en.toml @@ -0,0 +1,4 @@ +[Test] +Body = "This is a test" +Welcome = "Welcome {{.Name}}" +"String.EN.only" = "This key only exists in EN" \ No newline at end of file diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..3b1f2a6 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,162 @@ +package mailer + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "os" + "path/filepath" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/mailer/transport" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/jordan-wright/email" + "github.com/rs/zerolog/log" +) + +var ( + ErrEmailTemplateNotFound = errors.New("email template not found") + emailTemplatePasswordReset = "password_reset" // /app/templates/email/password_reset/**. + emailTemplateAccountConfirmation = "account_confirmation" // /app/templates/email/account_confirmation/** +) + +type Mailer struct { + Config config.Mailer + Transport transport.MailTransporter + Templates map[string]*template.Template +} + +func New(config config.Mailer, transport transport.MailTransporter) *Mailer { + return &Mailer{ + Config: config, + Transport: transport, + Templates: map[string]*template.Template{}, + } +} + +func NewWithConfig(cfg config.Mailer, smtpConfig transport.SMTPMailTransportConfig) (*Mailer, error) { + var mailer *Mailer + + switch config.MailerTransporter(cfg.Transporter) { + case config.MailerTransporterMock: + log.Warn().Msg("Initializing mock mailer") + mailer = New(cfg, transport.NewMock()) + case config.MailerTransporterSMTP: + mailer = New(cfg, transport.NewSMTP(smtpConfig)) + default: + return nil, fmt.Errorf("unsupported mail transporter: %s", cfg.Transporter) + } + + if err := mailer.ParseTemplates(); err != nil { + return nil, fmt.Errorf("failed to parse mailer templates: %w", err) + } + + return mailer, nil +} + +func (m *Mailer) ParseTemplates() error { + files, err := os.ReadDir(m.Config.WebTemplatesEmailBaseDirAbs) + if err != nil { + log.Error().Str("dir", m.Config.WebTemplatesEmailBaseDirAbs).Err(err).Msg("Failed to read email templates directory while parsing templates") + return fmt.Errorf("failed to read email templates directory while parsing templates: %w", err) + } + + for _, file := range files { + if !file.IsDir() { + continue + } + + tmpl, err := template.ParseGlob(filepath.Join(m.Config.WebTemplatesEmailBaseDirAbs, file.Name(), "**")) + if err != nil { + log.Error().Str("template", file.Name()).Err(err).Msg("Failed to parse email template files as glob") + return fmt.Errorf("failed to parse email template files as glob: %w", err) + } + + m.Templates[file.Name()] = tmpl + } + + return nil +} + +func (m *Mailer) SendPasswordReset(ctx context.Context, to string, passwordResetLink string) error { + log := util.LogFromContext(ctx).With().Str("component", "mailer").Str("email_template", emailTemplatePasswordReset).Logger() + + tmpl, ok := m.Templates[emailTemplatePasswordReset] + if !ok { + log.Error().Msg("Password reset email template not found") + return ErrEmailTemplateNotFound + } + + data := map[string]interface{}{ + "passwordResetLink": passwordResetLink, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + log.Error().Err(err).Msg("Failed to execute password reset email template") + return fmt.Errorf("failed to execute password reset email template: %w", err) + } + + mail := email.NewEmail() + + mail.From = m.Config.DefaultSender + mail.To = []string{to} + mail.Subject = "Password reset" + mail.HTML = buf.Bytes() + + if !m.Config.Send { + log.Warn().Str("to", to).Str("passwordResetLink", passwordResetLink).Msg("Sending has been disabled in mailer config, skipping password reset email") + return nil + } + + if err := m.Transport.Send(mail); err != nil { + log.Debug().Err(err).Msg("Failed to send password reset email") + return fmt.Errorf("failed to send password reset email: %w", err) + } + + log.Debug().Msg("Successfully sent password reset email") + + return nil +} + +func (m *Mailer) SendAccountConfirmation(ctx context.Context, to string, payload dto.ConfirmatioNotificationPayload) error { + log := util.LogFromContext(ctx) + + tmpl, ok := m.Templates[emailTemplateAccountConfirmation] + if !ok { + log.Error().Msg("Account confirmation email template not found") + return ErrEmailTemplateNotFound + } + + data := map[string]interface{}{ + "confirmationLink": payload.ConfirmationLink, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + log.Error().Err(err).Msg("Failed to execute account confirmation email template") + return fmt.Errorf("failed to execute account confirmation email template: %w", err) + } + + mail := email.NewEmail() + + mail.From = m.Config.DefaultSender + mail.To = []string{to} + mail.Subject = "Account confirmation" + mail.HTML = buf.Bytes() + + if !m.Config.Send { + log.Warn().Str("to", to).Msg("Sending has been disabled in mailer config, skipping account confirmation email") + return nil + } + + if err := m.Transport.Send(mail); err != nil { + log.Debug().Err(err).Msg("Failed to send account confirmation email") + return fmt.Errorf("failed to send account confirmation email: %w", err) + } + + return nil +} diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go new file mode 100644 index 0000000..a231321 --- /dev/null +++ b/internal/mailer/mailer_test.go @@ -0,0 +1,38 @@ +package mailer_test + +import ( + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMailerSendPasswordReset(t *testing.T) { + ctx := t.Context() + fix := fixtures.Fixtures() + + mailer := test.NewTestMailer(t) + mailTransport := test.GetTestMailerMockTransport(t, mailer) + mailTransport.Expect(1) + + //nolint:gosec + passwordResetLink := "http://localhost/password/reset/12345" + err := mailer.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink) + require.NoError(t, err) + + mailTransport.WaitWithTimeout(time.Second) + + mail := mailTransport.GetLastSentMail() + mails := mailTransport.GetSentMails() + require.NotNil(t, mail) + require.Len(t, mails, 1) + assert.Equal(t, mailer.Config.DefaultSender, mail.From) + assert.Len(t, mail.To, 1) + assert.Equal(t, fix.User1.Username.String, mail.To[0]) + assert.Equal(t, test.TestMailerDefaultSender, mail.From) + assert.Equal(t, "Password reset", mail.Subject) + assert.Contains(t, string(mail.HTML), passwordResetLink) +} diff --git a/internal/mailer/transport/mock.go b/internal/mailer/transport/mock.go new file mode 100644 index 0000000..6fc28b2 --- /dev/null +++ b/internal/mailer/transport/mock.go @@ -0,0 +1,95 @@ +package transport + +import ( + "errors" + "fmt" + "log" + "sync" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/jordan-wright/email" +) + +const defaultWaitTimeout = time.Second * 10 + +type MockMailTransport struct { + sync.RWMutex + mails []*email.Email + OnMailSent func(mail email.Email) // non pointer to prevent concurrent read errors + wg sync.WaitGroup + expected int +} + +func NewMock() *MockMailTransport { + return &MockMailTransport{ + RWMutex: sync.RWMutex{}, + mails: make([]*email.Email, 0), + OnMailSent: func(_ email.Email) {}, + } +} + +func (m *MockMailTransport) Send(mail *email.Email) error { + m.Lock() + defer m.Unlock() + + // Calling wg.Done might panic leaving a user clueless what was the reason of test failure. + // We will add more information before exiting. + defer func() { + rcp := recover() + if rcp == nil { + return + } + + err, ok := rcp.(error) + if !ok { + err = fmt.Errorf("%v", rcp) + } + + log.Fatalf("Unexpected email sent! MockMailTransport panicked: %s", err) + }() + + m.mails = append(m.mails, mail) + m.OnMailSent(*mail) + + if m.expected > 0 { + m.wg.Done() + } + + return nil +} + +func (m *MockMailTransport) GetLastSentMail() *email.Email { + m.RLock() + defer m.RUnlock() + + if len(m.mails) == 0 { + return nil + } + + return m.mails[len(m.mails)-1] +} + +func (m *MockMailTransport) GetSentMails() []*email.Email { + m.RLock() + defer m.RUnlock() + + return m.mails +} + +// Expect adds the mailCnt to a waitgroup. Done() is called by Send +func (m *MockMailTransport) Expect(mailCnt int) { + m.expected = mailCnt + m.wg.Add(mailCnt) +} + +// Wait until all expected mails have arrived +func (m *MockMailTransport) Wait() { + m.WaitWithTimeout(defaultWaitTimeout) +} + +func (m *MockMailTransport) WaitWithTimeout(timeout time.Duration) { + if err := util.WaitTimeout(&m.wg, timeout); errors.Is(err, util.ErrWaitTimeout) { + log.Fatalf("Timeout waiting for %d mails to be sent", m.expected) + } +} diff --git a/internal/mailer/transport/smtp.go b/internal/mailer/transport/smtp.go new file mode 100644 index 0000000..2c57e49 --- /dev/null +++ b/internal/mailer/transport/smtp.go @@ -0,0 +1,56 @@ +package transport + +import ( + "fmt" + "net" + "net/smtp" + "strconv" + + "github.com/jordan-wright/email" +) + +type SMTPMailTransport struct { + config SMTPMailTransportConfig + addr string + auth smtp.Auth +} + +func NewSMTP(config SMTPMailTransportConfig) *SMTPMailTransport { + mailTransport := &SMTPMailTransport{ + config: config, + addr: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), + auth: nil, + } + + switch config.AuthType { + case SMTPAuthTypePlain: + mailTransport.auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) + case SMTPAuthTypeCRAMMD5: + mailTransport.auth = smtp.CRAMMD5Auth(config.Username, config.Password) + case SMTPAuthTypeLogin: + mailTransport.auth = LoginAuth(config.Username, config.Password, config.Host) + } + + return mailTransport +} + +func (m *SMTPMailTransport) Send(mail *email.Email) error { + var err error + + switch m.config.Encryption { + case SMTPEncryptionNone: + err = mail.Send(m.addr, m.auth) + case SMTPEncryptionTLS: + err = mail.SendWithTLS(m.addr, m.auth, m.config.TLSConfig) + case SMTPEncryptionStartTLS: + err = mail.SendWithStartTLS(m.addr, m.auth, m.config.TLSConfig) + default: + return fmt.Errorf("invalid SMTP encryption %q", m.config.Encryption) + } + + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} diff --git a/internal/mailer/transport/smtp_config.go b/internal/mailer/transport/smtp_config.go new file mode 100644 index 0000000..c2dbcc3 --- /dev/null +++ b/internal/mailer/transport/smtp_config.go @@ -0,0 +1,81 @@ +package transport + +import ( + "crypto/tls" + "fmt" + "strings" +) + +type SMTPAuthType int + +const ( + // SMTPAuthTypeNone indicates no SMTP authentication should be performed. + SMTPAuthTypeNone SMTPAuthType = iota + // SMTPAuthTypePlain indicates SMTP authentication should be performed using the "AUTH PLAIN" protocol. + SMTPAuthTypePlain + // SMTPAuthTypeCRAMMD5 indicates SMTP authentication should be performed using the "CRAM-MD5" protocol. + SMTPAuthTypeCRAMMD5 + // SMTPAuthTypeLogin indicates SMTP authentication should be performed using the "LOGIN" protocol. + SMTPAuthTypeLogin +) + +func (t SMTPAuthType) String() string { + switch t { + case SMTPAuthTypeNone: + return "none" + case SMTPAuthTypePlain: + return "plain" + case SMTPAuthTypeCRAMMD5: + return "cram-md5" + case SMTPAuthTypeLogin: + return "login" + default: + return fmt.Sprintf("unknown (%d)", t) + } +} + +func SMTPAuthTypeFromString(s string) SMTPAuthType { + switch strings.ToLower(s) { + case "plain": + return SMTPAuthTypePlain + case "cram-md5": + return SMTPAuthTypeCRAMMD5 + case "login": + return SMTPAuthTypeLogin + default: + return SMTPAuthTypeNone + } +} + +type SMTPEncryption string + +const ( + SMTPEncryptionNone SMTPEncryption = "none" + SMTPEncryptionTLS SMTPEncryption = "tls" + SMTPEncryptionStartTLS SMTPEncryption = "starttls" +) + +func (e SMTPEncryption) String() string { + return string(e) +} + +func SMTPEncryptionFromString(s string) SMTPEncryption { + switch strings.ToLower(s) { + case "tls": + return SMTPEncryptionTLS + case "starttls": + return SMTPEncryptionStartTLS + default: + return SMTPEncryptionNone + } +} + +type SMTPMailTransportConfig struct { + Host string + Port int + AuthType SMTPAuthType `json:"-"` // iota + Username string + Password string `json:"-"` // sensitive + Encryption SMTPEncryption `json:"-"` // iota + TLSConfig *tls.Config `json:"-"` // pointer +} diff --git a/internal/mailer/transport/smtp_login_auth.go b/internal/mailer/transport/smtp_login_auth.go new file mode 100644 index 0000000..fc8efc1 --- /dev/null +++ b/internal/mailer/transport/smtp_login_auth.go @@ -0,0 +1,50 @@ +package transport + +import ( + "errors" + "fmt" + "net/smtp" +) + +type loginAuth struct { + username []byte + password []byte + host string +} + +func LoginAuth(username string, password string, host ...string) smtp.Auth { + auth := &loginAuth{ + username: []byte(username), + password: []byte(password), + host: "", + } + + if len(host) > 0 { + auth.host = host[0] + } + + return auth +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + if len(a.host) > 0 && server.Name != a.host { + return "", nil, errors.New("wrong host name") + } + + return "LOGIN", a.username, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return a.username, nil + case "Password:": + return a.password, nil + default: + return nil, fmt.Errorf("unknown challenge received from server: %q", fromServer) + } + } + + return nil, nil +} diff --git a/internal/mailer/transport/transport.go b/internal/mailer/transport/transport.go new file mode 100644 index 0000000..f21a6dd --- /dev/null +++ b/internal/mailer/transport/transport.go @@ -0,0 +1,7 @@ +package transport + +import "github.com/jordan-wright/email" + +type MailTransporter interface { + Send(mail *email.Email) error +} diff --git a/internal/metrics/service.go b/internal/metrics/service.go new file mode 100644 index 0000000..2cfbb71 --- /dev/null +++ b/internal/metrics/service.go @@ -0,0 +1,46 @@ +package metrics + +import ( + "context" + "database/sql" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/metrics/users" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/dlmiddlecote/sqlstats" + "github.com/prometheus/client_golang/prometheus" +) + +type Service struct { + config config.Server + db *sql.DB +} + +func New(config config.Server, db *sql.DB) (*Service, error) { + return &Service{ + config: config, + db: db, + }, nil +} + +func (s *Service) RegisterMetrics(ctx context.Context) error { + log := util.LogFromContext(ctx) + + var metrics []prometheus.Collector + + // custom metrics + metrics = append(metrics, users.Metrics(ctx, users.NewDatabaseMetricsCollector(s.db))...) + + // sqlstats metrics, see https://github.com/dlmiddlecote/sqlstats?tab=readme-ov-file#exposed-metrics for the exposed metrics + metrics = append(metrics, sqlstats.NewStatsCollector(s.config.Database.Database, s.db)) + + for _, metric := range metrics { + if err := prometheus.Register(metric); err != nil { + log.Error().Err(err).Msg("Failed to register metric") + return fmt.Errorf("failed to register metric: %w", err) + } + } + + return nil +} diff --git a/internal/metrics/users/collector.go b/internal/metrics/users/collector.go new file mode 100644 index 0000000..f3676f3 --- /dev/null +++ b/internal/metrics/users/collector.go @@ -0,0 +1,29 @@ +package users + +import ( + "context" + "database/sql" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util" +) + +type DatabaseMetricsCollector struct { + db *sql.DB +} + +func NewDatabaseMetricsCollector(db *sql.DB) *DatabaseMetricsCollector { + return &DatabaseMetricsCollector{db: db} +} + +func (c DatabaseMetricsCollector) GetTotalUsersCount(ctx context.Context) float64 { + log := util.LogFromContext(ctx) + + count, err := models.Users().Count(ctx, c.db) + if err != nil { + log.Error().Err(err).Msg("Failed to total user count") + return 0 + } + + return float64(count) +} diff --git a/internal/metrics/users/metrics.go b/internal/metrics/users/metrics.go new file mode 100644 index 0000000..f45a50d --- /dev/null +++ b/internal/metrics/users/metrics.go @@ -0,0 +1,27 @@ +package users + +import ( + "context" + + "github.com/prometheus/client_golang/prometheus" +) + +type MetricsCollector interface { + GetTotalUsersCount(ctx context.Context) float64 +} + +const ( + MetricNameTotalUsers = "total_users" +) + +func Metrics(ctx context.Context, collector MetricsCollector) []prometheus.Collector { + return []prometheus.Collector{ + prometheus.NewGaugeFunc( + prometheus.GaugeOpts{ + Name: MetricNameTotalUsers, + Help: "Total users", + }, + func() float64 { return collector.GetTotalUsersCount(ctx) }, + ), + } +} diff --git a/internal/models/access_tokens.go b/internal/models/access_tokens.go new file mode 100644 index 0000000..51fe813 --- /dev/null +++ b/internal/models/access_tokens.go @@ -0,0 +1,962 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// AccessToken is an object representing the database table. +type AccessToken struct { + Token string `boil:"token" json:"token" toml:"token" yaml:"token"` + ValidUntil time.Time `boil:"valid_until" json:"valid_until" toml:"valid_until" yaml:"valid_until"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *accessTokenR `boil:"-" json:"-" toml:"-" yaml:"-"` + L accessTokenL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var AccessTokenColumns = struct { + Token string + ValidUntil string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "token", + ValidUntil: "valid_until", + UserID: "user_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var AccessTokenTableColumns = struct { + Token string + ValidUntil string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "access_tokens.token", + ValidUntil: "access_tokens.valid_until", + UserID: "access_tokens.user_id", + CreatedAt: "access_tokens.created_at", + UpdatedAt: "access_tokens.updated_at", +} + +// Generated where + +type whereHelperstring struct{ field string } + +func (w whereHelperstring) EQ(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperstring) NEQ(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperstring) LT(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperstring) LTE(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperstring) GT(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperstring) GTE(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } +func (w whereHelperstring) LIKE(x string) qm.QueryMod { return qm.Where(w.field+" LIKE ?", x) } +func (w whereHelperstring) NLIKE(x string) qm.QueryMod { return qm.Where(w.field+" NOT LIKE ?", x) } +func (w whereHelperstring) ILIKE(x string) qm.QueryMod { return qm.Where(w.field+" ILIKE ?", x) } +func (w whereHelperstring) NILIKE(x string) qm.QueryMod { return qm.Where(w.field+" NOT ILIKE ?", x) } +func (w whereHelperstring) SIMILAR(x string) qm.QueryMod { return qm.Where(w.field+" SIMILAR TO ?", x) } +func (w whereHelperstring) NSIMILAR(x string) qm.QueryMod { + return qm.Where(w.field+" NOT SIMILAR TO ?", x) +} +func (w whereHelperstring) IN(slice []string) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...) +} +func (w whereHelperstring) NIN(slice []string) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...) +} + +type whereHelpertime_Time struct{ field string } + +func (w whereHelpertime_Time) EQ(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.EQ, x) +} +func (w whereHelpertime_Time) NEQ(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.NEQ, x) +} +func (w whereHelpertime_Time) LT(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpertime_Time) LTE(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpertime_Time) GT(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpertime_Time) GTE(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +var AccessTokenWhere = struct { + Token whereHelperstring + ValidUntil whereHelpertime_Time + UserID whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + Token: whereHelperstring{field: "\"access_tokens\".\"token\""}, + ValidUntil: whereHelpertime_Time{field: "\"access_tokens\".\"valid_until\""}, + UserID: whereHelperstring{field: "\"access_tokens\".\"user_id\""}, + CreatedAt: whereHelpertime_Time{field: "\"access_tokens\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"access_tokens\".\"updated_at\""}, +} + +// AccessTokenRels is where relationship names are stored. +var AccessTokenRels = struct { + User string +}{ + User: "User", +} + +// accessTokenR is where relationships are stored. +type accessTokenR struct { + User *User `boil:"User" json:"User" toml:"User" yaml:"User"` +} + +// NewStruct creates a new relationship struct +func (*accessTokenR) NewStruct() *accessTokenR { + return &accessTokenR{} +} + +func (o *AccessToken) GetUser() *User { + if o == nil { + return nil + } + + return o.R.GetUser() +} + +func (r *accessTokenR) GetUser() *User { + if r == nil { + return nil + } + + return r.User +} + +// accessTokenL is where Load methods for each relationship are stored. +type accessTokenL struct{} + +var ( + accessTokenAllColumns = []string{"token", "valid_until", "user_id", "created_at", "updated_at"} + accessTokenColumnsWithoutDefault = []string{"valid_until", "user_id", "created_at", "updated_at"} + accessTokenColumnsWithDefault = []string{"token"} + accessTokenPrimaryKeyColumns = []string{"token"} + accessTokenGeneratedColumns = []string{} +) + +type ( + // AccessTokenSlice is an alias for a slice of pointers to AccessToken. + // This should almost always be used instead of []AccessToken. + AccessTokenSlice []*AccessToken + + accessTokenQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + accessTokenType = reflect.TypeOf(&AccessToken{}) + accessTokenMapping = queries.MakeStructMapping(accessTokenType) + accessTokenPrimaryKeyMapping, _ = queries.BindMapping(accessTokenType, accessTokenMapping, accessTokenPrimaryKeyColumns) + accessTokenInsertCacheMut sync.RWMutex + accessTokenInsertCache = make(map[string]insertCache) + accessTokenUpdateCacheMut sync.RWMutex + accessTokenUpdateCache = make(map[string]updateCache) + accessTokenUpsertCacheMut sync.RWMutex + accessTokenUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single accessToken record from the query. +func (q accessTokenQuery) One(ctx context.Context, exec boil.ContextExecutor) (*AccessToken, error) { + o := &AccessToken{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for access_tokens") + } + + return o, nil +} + +// All returns all AccessToken records from the query. +func (q accessTokenQuery) All(ctx context.Context, exec boil.ContextExecutor) (AccessTokenSlice, error) { + var o []*AccessToken + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to AccessToken slice") + } + + return o, nil +} + +// Count returns the count of all AccessToken records in the query. +func (q accessTokenQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count access_tokens rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q accessTokenQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if access_tokens exists") + } + + return count > 0, nil +} + +// User pointed to by the foreign key. +func (o *AccessToken) User(mods ...qm.QueryMod) userQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.UserID), + } + + queryMods = append(queryMods, mods...) + + return Users(queryMods...) +} + +// LoadUser allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (accessTokenL) LoadUser(ctx context.Context, e boil.ContextExecutor, singular bool, maybeAccessToken interface{}, mods queries.Applicator) error { + var slice []*AccessToken + var object *AccessToken + + if singular { + var ok bool + object, ok = maybeAccessToken.(*AccessToken) + if !ok { + object = new(AccessToken) + ok = queries.SetFromEmbeddedStruct(&object, &maybeAccessToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeAccessToken)) + } + } + } else { + s, ok := maybeAccessToken.(*[]*AccessToken) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeAccessToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeAccessToken)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &accessTokenR{} + } + args[object.UserID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &accessTokenR{} + } + + args[obj.UserID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`users`), + qm.WhereIn(`users.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load User") + } + + var resultSlice []*User + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice User") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for users") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for users") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.AccessTokens = append(foreign.R.AccessTokens, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.UserID == foreign.ID { + local.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.AccessTokens = append(foreign.R.AccessTokens, local) + break + } + } + } + + return nil +} + +// SetUser of the accessToken to the related item. +// Sets o.R.User to related. +// Adds o to related.R.AccessTokens. +func (o *AccessToken) SetUser(ctx context.Context, exec boil.ContextExecutor, insert bool, related *User) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"access_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, accessTokenPrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.UserID = related.ID + if o.R == nil { + o.R = &accessTokenR{ + User: related, + } + } else { + o.R.User = related + } + + if related.R == nil { + related.R = &userR{ + AccessTokens: AccessTokenSlice{o}, + } + } else { + related.R.AccessTokens = append(related.R.AccessTokens, o) + } + + return nil +} + +// AccessTokens retrieves all the records using an executor. +func AccessTokens(mods ...qm.QueryMod) accessTokenQuery { + mods = append(mods, qm.From("\"access_tokens\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"access_tokens\".*"}) + } + + return accessTokenQuery{q} +} + +// FindAccessToken retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindAccessToken(ctx context.Context, exec boil.ContextExecutor, token string, selectCols ...string) (*AccessToken, error) { + accessTokenObj := &AccessToken{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"access_tokens\" where \"token\"=$1", sel, + ) + + q := queries.Raw(query, token) + + err := q.Bind(ctx, exec, accessTokenObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from access_tokens") + } + + return accessTokenObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *AccessToken) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no access_tokens provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(accessTokenColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + accessTokenInsertCacheMut.RLock() + cache, cached := accessTokenInsertCache[key] + accessTokenInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + accessTokenAllColumns, + accessTokenColumnsWithDefault, + accessTokenColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(accessTokenType, accessTokenMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(accessTokenType, accessTokenMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"access_tokens\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"access_tokens\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into access_tokens") + } + + if !cached { + accessTokenInsertCacheMut.Lock() + accessTokenInsertCache[key] = cache + accessTokenInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the AccessToken. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *AccessToken) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + accessTokenUpdateCacheMut.RLock() + cache, cached := accessTokenUpdateCache[key] + accessTokenUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + accessTokenAllColumns, + accessTokenPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update access_tokens, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"access_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, accessTokenPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(accessTokenType, accessTokenMapping, append(wl, accessTokenPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update access_tokens row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for access_tokens") + } + + if !cached { + accessTokenUpdateCacheMut.Lock() + accessTokenUpdateCache[key] = cache + accessTokenUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q accessTokenQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for access_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for access_tokens") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o AccessTokenSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), accessTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"access_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, accessTokenPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in accessToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all accessToken") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *AccessToken) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no access_tokens provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(accessTokenColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + accessTokenUpsertCacheMut.RLock() + cache, cached := accessTokenUpsertCache[key] + accessTokenUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + accessTokenAllColumns, + accessTokenColumnsWithDefault, + accessTokenColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + accessTokenAllColumns, + accessTokenPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert access_tokens, could not build update column list") + } + + ret := strmangle.SetComplement(accessTokenAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(accessTokenPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert access_tokens, could not build conflict column list") + } + + conflict = make([]string, len(accessTokenPrimaryKeyColumns)) + copy(conflict, accessTokenPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"access_tokens\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(accessTokenType, accessTokenMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(accessTokenType, accessTokenMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert access_tokens") + } + + if !cached { + accessTokenUpsertCacheMut.Lock() + accessTokenUpsertCache[key] = cache + accessTokenUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single AccessToken record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *AccessToken) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no AccessToken provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), accessTokenPrimaryKeyMapping) + sql := "DELETE FROM \"access_tokens\" WHERE \"token\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from access_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for access_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q accessTokenQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no accessTokenQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from access_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for access_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o AccessTokenSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), accessTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"access_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, accessTokenPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from accessToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for access_tokens") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *AccessToken) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindAccessToken(ctx, exec, o.Token) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *AccessTokenSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := AccessTokenSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), accessTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"access_tokens\".* FROM \"access_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, accessTokenPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in AccessTokenSlice") + } + + *o = slice + + return nil +} + +// AccessTokenExists checks if the AccessToken row exists. +func AccessTokenExists(ctx context.Context, exec boil.ContextExecutor, token string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"access_tokens\" where \"token\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, token) + } + row := exec.QueryRowContext(ctx, sql, token) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if access_tokens exists") + } + + return exists, nil +} + +// Exists checks if the AccessToken row exists. +func (o *AccessToken) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return AccessTokenExists(ctx, exec, o.Token) +} diff --git a/internal/models/access_tokens_test.go b/internal/models/access_tokens_test.go new file mode 100644 index 0000000..5c90a72 --- /dev/null +++ b/internal/models/access_tokens_test.go @@ -0,0 +1,701 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testAccessTokens(t *testing.T) { + t.Parallel() + + query := AccessTokens() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testAccessTokensDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testAccessTokensQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := AccessTokens().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testAccessTokensSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := AccessTokenSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testAccessTokensExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := AccessTokenExists(ctx, tx, o.Token) + if err != nil { + t.Errorf("Unable to check if AccessToken exists: %s", err) + } + if !e { + t.Errorf("Expected AccessTokenExists to return true, but got false.") + } +} + +func testAccessTokensFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + accessTokenFound, err := FindAccessToken(ctx, tx, o.Token) + if err != nil { + t.Error(err) + } + + if accessTokenFound == nil { + t.Error("want a record, got nil") + } +} + +func testAccessTokensBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = AccessTokens().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testAccessTokensOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := AccessTokens().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testAccessTokensAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + accessTokenOne := &AccessToken{} + accessTokenTwo := &AccessToken{} + if err = randomize.Struct(seed, accessTokenOne, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + if err = randomize.Struct(seed, accessTokenTwo, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = accessTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = accessTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := AccessTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testAccessTokensCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + accessTokenOne := &AccessToken{} + accessTokenTwo := &AccessToken{} + if err = randomize.Struct(seed, accessTokenOne, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + if err = randomize.Struct(seed, accessTokenTwo, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = accessTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = accessTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testAccessTokensInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testAccessTokensInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(accessTokenPrimaryKeyColumns, accessTokenColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testAccessTokenToOneUserUsingUser(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var local AccessToken + var foreign User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &local, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + if err := randomize.Struct(seed, &foreign, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + local.UserID = foreign.ID + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.User().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.ID != foreign.ID { + t.Errorf("want: %v, got %v", foreign.ID, check.ID) + } + + slice := AccessTokenSlice{&local} + if err = local.L.LoadUser(ctx, tx, false, (*[]*AccessToken)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + + local.R.User = nil + if err = local.L.LoadUser(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testAccessTokenToOneSetOpUserUsingUser(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a AccessToken + var b, c User + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, accessTokenDBTypes, false, strmangle.SetComplement(accessTokenPrimaryKeyColumns, accessTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*User{&b, &c} { + err = a.SetUser(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.User != x { + t.Error("relationship struct not set to correct value") + } + + if x.R.AccessTokens[0] != &a { + t.Error("failed to append to foreign relationship struct") + } + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID) + } + + zero := reflect.Zero(reflect.TypeOf(a.UserID)) + reflect.Indirect(reflect.ValueOf(&a.UserID)).Set(zero) + + if err = a.Reload(ctx, tx); err != nil { + t.Fatal("failed to reload", err) + } + + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID, x.ID) + } + } +} + +func testAccessTokensReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testAccessTokensReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := AccessTokenSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testAccessTokensSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := AccessTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + accessTokenDBTypes = map[string]string{`Token`: `uuid`, `ValidUntil`: `timestamp with time zone`, `UserID`: `uuid`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`} + _ = bytes.MinRead +) + +func testAccessTokensUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(accessTokenPrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(accessTokenAllColumns) == len(accessTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testAccessTokensSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(accessTokenAllColumns) == len(accessTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &AccessToken{} + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, accessTokenDBTypes, true, accessTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(accessTokenAllColumns, accessTokenPrimaryKeyColumns) { + fields = accessTokenAllColumns + } else { + fields = strmangle.SetComplement( + accessTokenAllColumns, + accessTokenPrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := AccessTokenSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testAccessTokensUpsert(t *testing.T) { + t.Parallel() + + if len(accessTokenAllColumns) == len(accessTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := AccessToken{} + if err = randomize.Struct(seed, &o, accessTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert AccessToken: %s", err) + } + + count, err := AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, accessTokenDBTypes, false, accessTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize AccessToken struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert AccessToken: %s", err) + } + + count, err = AccessTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/models/app_user_profiles.go b/internal/models/app_user_profiles.go new file mode 100644 index 0000000..e7fba1c --- /dev/null +++ b/internal/models/app_user_profiles.go @@ -0,0 +1,928 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// AppUserProfile is an object representing the database table. +type AppUserProfile struct { + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + LegalAcceptedAt null.Time `boil:"legal_accepted_at" json:"legal_accepted_at,omitempty" toml:"legal_accepted_at" yaml:"legal_accepted_at,omitempty"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *appUserProfileR `boil:"-" json:"-" toml:"-" yaml:"-"` + L appUserProfileL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var AppUserProfileColumns = struct { + UserID string + LegalAcceptedAt string + CreatedAt string + UpdatedAt string +}{ + UserID: "user_id", + LegalAcceptedAt: "legal_accepted_at", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var AppUserProfileTableColumns = struct { + UserID string + LegalAcceptedAt string + CreatedAt string + UpdatedAt string +}{ + UserID: "app_user_profiles.user_id", + LegalAcceptedAt: "app_user_profiles.legal_accepted_at", + CreatedAt: "app_user_profiles.created_at", + UpdatedAt: "app_user_profiles.updated_at", +} + +// Generated where + +type whereHelpernull_Time struct{ field string } + +func (w whereHelpernull_Time) EQ(x null.Time) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, false, x) +} +func (w whereHelpernull_Time) NEQ(x null.Time) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, true, x) +} +func (w whereHelpernull_Time) LT(x null.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpernull_Time) LTE(x null.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpernull_Time) GT(x null.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpernull_Time) GTE(x null.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +func (w whereHelpernull_Time) IsNull() qm.QueryMod { return qmhelper.WhereIsNull(w.field) } +func (w whereHelpernull_Time) IsNotNull() qm.QueryMod { return qmhelper.WhereIsNotNull(w.field) } + +var AppUserProfileWhere = struct { + UserID whereHelperstring + LegalAcceptedAt whereHelpernull_Time + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + UserID: whereHelperstring{field: "\"app_user_profiles\".\"user_id\""}, + LegalAcceptedAt: whereHelpernull_Time{field: "\"app_user_profiles\".\"legal_accepted_at\""}, + CreatedAt: whereHelpertime_Time{field: "\"app_user_profiles\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"app_user_profiles\".\"updated_at\""}, +} + +// AppUserProfileRels is where relationship names are stored. +var AppUserProfileRels = struct { + User string +}{ + User: "User", +} + +// appUserProfileR is where relationships are stored. +type appUserProfileR struct { + User *User `boil:"User" json:"User" toml:"User" yaml:"User"` +} + +// NewStruct creates a new relationship struct +func (*appUserProfileR) NewStruct() *appUserProfileR { + return &appUserProfileR{} +} + +func (o *AppUserProfile) GetUser() *User { + if o == nil { + return nil + } + + return o.R.GetUser() +} + +func (r *appUserProfileR) GetUser() *User { + if r == nil { + return nil + } + + return r.User +} + +// appUserProfileL is where Load methods for each relationship are stored. +type appUserProfileL struct{} + +var ( + appUserProfileAllColumns = []string{"user_id", "legal_accepted_at", "created_at", "updated_at"} + appUserProfileColumnsWithoutDefault = []string{"user_id", "created_at", "updated_at"} + appUserProfileColumnsWithDefault = []string{"legal_accepted_at"} + appUserProfilePrimaryKeyColumns = []string{"user_id"} + appUserProfileGeneratedColumns = []string{} +) + +type ( + // AppUserProfileSlice is an alias for a slice of pointers to AppUserProfile. + // This should almost always be used instead of []AppUserProfile. + AppUserProfileSlice []*AppUserProfile + + appUserProfileQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + appUserProfileType = reflect.TypeOf(&AppUserProfile{}) + appUserProfileMapping = queries.MakeStructMapping(appUserProfileType) + appUserProfilePrimaryKeyMapping, _ = queries.BindMapping(appUserProfileType, appUserProfileMapping, appUserProfilePrimaryKeyColumns) + appUserProfileInsertCacheMut sync.RWMutex + appUserProfileInsertCache = make(map[string]insertCache) + appUserProfileUpdateCacheMut sync.RWMutex + appUserProfileUpdateCache = make(map[string]updateCache) + appUserProfileUpsertCacheMut sync.RWMutex + appUserProfileUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single appUserProfile record from the query. +func (q appUserProfileQuery) One(ctx context.Context, exec boil.ContextExecutor) (*AppUserProfile, error) { + o := &AppUserProfile{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for app_user_profiles") + } + + return o, nil +} + +// All returns all AppUserProfile records from the query. +func (q appUserProfileQuery) All(ctx context.Context, exec boil.ContextExecutor) (AppUserProfileSlice, error) { + var o []*AppUserProfile + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to AppUserProfile slice") + } + + return o, nil +} + +// Count returns the count of all AppUserProfile records in the query. +func (q appUserProfileQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count app_user_profiles rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q appUserProfileQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if app_user_profiles exists") + } + + return count > 0, nil +} + +// User pointed to by the foreign key. +func (o *AppUserProfile) User(mods ...qm.QueryMod) userQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.UserID), + } + + queryMods = append(queryMods, mods...) + + return Users(queryMods...) +} + +// LoadUser allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (appUserProfileL) LoadUser(ctx context.Context, e boil.ContextExecutor, singular bool, maybeAppUserProfile interface{}, mods queries.Applicator) error { + var slice []*AppUserProfile + var object *AppUserProfile + + if singular { + var ok bool + object, ok = maybeAppUserProfile.(*AppUserProfile) + if !ok { + object = new(AppUserProfile) + ok = queries.SetFromEmbeddedStruct(&object, &maybeAppUserProfile) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeAppUserProfile)) + } + } + } else { + s, ok := maybeAppUserProfile.(*[]*AppUserProfile) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeAppUserProfile) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeAppUserProfile)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &appUserProfileR{} + } + args[object.UserID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &appUserProfileR{} + } + + args[obj.UserID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`users`), + qm.WhereIn(`users.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load User") + } + + var resultSlice []*User + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice User") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for users") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for users") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.AppUserProfile = object + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.UserID == foreign.ID { + local.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.AppUserProfile = local + break + } + } + } + + return nil +} + +// SetUser of the appUserProfile to the related item. +// Sets o.R.User to related. +// Adds o to related.R.AppUserProfile. +func (o *AppUserProfile) SetUser(ctx context.Context, exec boil.ContextExecutor, insert bool, related *User) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"app_user_profiles\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, appUserProfilePrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.UserID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.UserID = related.ID + if o.R == nil { + o.R = &appUserProfileR{ + User: related, + } + } else { + o.R.User = related + } + + if related.R == nil { + related.R = &userR{ + AppUserProfile: o, + } + } else { + related.R.AppUserProfile = o + } + + return nil +} + +// AppUserProfiles retrieves all the records using an executor. +func AppUserProfiles(mods ...qm.QueryMod) appUserProfileQuery { + mods = append(mods, qm.From("\"app_user_profiles\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"app_user_profiles\".*"}) + } + + return appUserProfileQuery{q} +} + +// FindAppUserProfile retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindAppUserProfile(ctx context.Context, exec boil.ContextExecutor, userID string, selectCols ...string) (*AppUserProfile, error) { + appUserProfileObj := &AppUserProfile{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"app_user_profiles\" where \"user_id\"=$1", sel, + ) + + q := queries.Raw(query, userID) + + err := q.Bind(ctx, exec, appUserProfileObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from app_user_profiles") + } + + return appUserProfileObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *AppUserProfile) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no app_user_profiles provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(appUserProfileColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + appUserProfileInsertCacheMut.RLock() + cache, cached := appUserProfileInsertCache[key] + appUserProfileInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + appUserProfileAllColumns, + appUserProfileColumnsWithDefault, + appUserProfileColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(appUserProfileType, appUserProfileMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(appUserProfileType, appUserProfileMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"app_user_profiles\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"app_user_profiles\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into app_user_profiles") + } + + if !cached { + appUserProfileInsertCacheMut.Lock() + appUserProfileInsertCache[key] = cache + appUserProfileInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the AppUserProfile. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *AppUserProfile) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + appUserProfileUpdateCacheMut.RLock() + cache, cached := appUserProfileUpdateCache[key] + appUserProfileUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + appUserProfileAllColumns, + appUserProfilePrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update app_user_profiles, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"app_user_profiles\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, appUserProfilePrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(appUserProfileType, appUserProfileMapping, append(wl, appUserProfilePrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update app_user_profiles row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for app_user_profiles") + } + + if !cached { + appUserProfileUpdateCacheMut.Lock() + appUserProfileUpdateCache[key] = cache + appUserProfileUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q appUserProfileQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for app_user_profiles") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for app_user_profiles") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o AppUserProfileSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), appUserProfilePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"app_user_profiles\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, appUserProfilePrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in appUserProfile slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all appUserProfile") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *AppUserProfile) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no app_user_profiles provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(appUserProfileColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + appUserProfileUpsertCacheMut.RLock() + cache, cached := appUserProfileUpsertCache[key] + appUserProfileUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + appUserProfileAllColumns, + appUserProfileColumnsWithDefault, + appUserProfileColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + appUserProfileAllColumns, + appUserProfilePrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert app_user_profiles, could not build update column list") + } + + ret := strmangle.SetComplement(appUserProfileAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(appUserProfilePrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert app_user_profiles, could not build conflict column list") + } + + conflict = make([]string, len(appUserProfilePrimaryKeyColumns)) + copy(conflict, appUserProfilePrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"app_user_profiles\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(appUserProfileType, appUserProfileMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(appUserProfileType, appUserProfileMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert app_user_profiles") + } + + if !cached { + appUserProfileUpsertCacheMut.Lock() + appUserProfileUpsertCache[key] = cache + appUserProfileUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single AppUserProfile record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *AppUserProfile) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no AppUserProfile provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), appUserProfilePrimaryKeyMapping) + sql := "DELETE FROM \"app_user_profiles\" WHERE \"user_id\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from app_user_profiles") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for app_user_profiles") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q appUserProfileQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no appUserProfileQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from app_user_profiles") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for app_user_profiles") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o AppUserProfileSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), appUserProfilePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"app_user_profiles\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, appUserProfilePrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from appUserProfile slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for app_user_profiles") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *AppUserProfile) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindAppUserProfile(ctx, exec, o.UserID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *AppUserProfileSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := AppUserProfileSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), appUserProfilePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"app_user_profiles\".* FROM \"app_user_profiles\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, appUserProfilePrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in AppUserProfileSlice") + } + + *o = slice + + return nil +} + +// AppUserProfileExists checks if the AppUserProfile row exists. +func AppUserProfileExists(ctx context.Context, exec boil.ContextExecutor, userID string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"app_user_profiles\" where \"user_id\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, userID) + } + row := exec.QueryRowContext(ctx, sql, userID) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if app_user_profiles exists") + } + + return exists, nil +} + +// Exists checks if the AppUserProfile row exists. +func (o *AppUserProfile) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return AppUserProfileExists(ctx, exec, o.UserID) +} diff --git a/internal/models/app_user_profiles_test.go b/internal/models/app_user_profiles_test.go new file mode 100644 index 0000000..e56a797 --- /dev/null +++ b/internal/models/app_user_profiles_test.go @@ -0,0 +1,697 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testAppUserProfiles(t *testing.T) { + t.Parallel() + + query := AppUserProfiles() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testAppUserProfilesDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testAppUserProfilesQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := AppUserProfiles().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testAppUserProfilesSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := AppUserProfileSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testAppUserProfilesExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := AppUserProfileExists(ctx, tx, o.UserID) + if err != nil { + t.Errorf("Unable to check if AppUserProfile exists: %s", err) + } + if !e { + t.Errorf("Expected AppUserProfileExists to return true, but got false.") + } +} + +func testAppUserProfilesFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + appUserProfileFound, err := FindAppUserProfile(ctx, tx, o.UserID) + if err != nil { + t.Error(err) + } + + if appUserProfileFound == nil { + t.Error("want a record, got nil") + } +} + +func testAppUserProfilesBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = AppUserProfiles().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testAppUserProfilesOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := AppUserProfiles().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testAppUserProfilesAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + appUserProfileOne := &AppUserProfile{} + appUserProfileTwo := &AppUserProfile{} + if err = randomize.Struct(seed, appUserProfileOne, appUserProfileDBTypes, false, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + if err = randomize.Struct(seed, appUserProfileTwo, appUserProfileDBTypes, false, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = appUserProfileOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = appUserProfileTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := AppUserProfiles().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testAppUserProfilesCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + appUserProfileOne := &AppUserProfile{} + appUserProfileTwo := &AppUserProfile{} + if err = randomize.Struct(seed, appUserProfileOne, appUserProfileDBTypes, false, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + if err = randomize.Struct(seed, appUserProfileTwo, appUserProfileDBTypes, false, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = appUserProfileOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = appUserProfileTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testAppUserProfilesInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testAppUserProfilesInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(appUserProfilePrimaryKeyColumns, appUserProfileColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testAppUserProfileToOneUserUsingUser(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var local AppUserProfile + var foreign User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &local, appUserProfileDBTypes, false, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + if err := randomize.Struct(seed, &foreign, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + local.UserID = foreign.ID + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.User().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.ID != foreign.ID { + t.Errorf("want: %v, got %v", foreign.ID, check.ID) + } + + slice := AppUserProfileSlice{&local} + if err = local.L.LoadUser(ctx, tx, false, (*[]*AppUserProfile)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + + local.R.User = nil + if err = local.L.LoadUser(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testAppUserProfileToOneSetOpUserUsingUser(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a AppUserProfile + var b, c User + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, appUserProfileDBTypes, false, strmangle.SetComplement(appUserProfilePrimaryKeyColumns, appUserProfileColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*User{&b, &c} { + err = a.SetUser(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.User != x { + t.Error("relationship struct not set to correct value") + } + + if x.R.AppUserProfile != &a { + t.Error("failed to append to foreign relationship struct") + } + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID) + } + + if exists, err := AppUserProfileExists(ctx, tx, a.UserID); err != nil { + t.Fatal(err) + } else if !exists { + t.Error("want 'a' to exist") + } + + } +} + +func testAppUserProfilesReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testAppUserProfilesReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := AppUserProfileSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testAppUserProfilesSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := AppUserProfiles().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + appUserProfileDBTypes = map[string]string{`UserID`: `uuid`, `LegalAcceptedAt`: `timestamp with time zone`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`} + _ = bytes.MinRead +) + +func testAppUserProfilesUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(appUserProfilePrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(appUserProfileAllColumns) == len(appUserProfilePrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfilePrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testAppUserProfilesSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(appUserProfileAllColumns) == len(appUserProfilePrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &AppUserProfile{} + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, appUserProfileDBTypes, true, appUserProfilePrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(appUserProfileAllColumns, appUserProfilePrimaryKeyColumns) { + fields = appUserProfileAllColumns + } else { + fields = strmangle.SetComplement( + appUserProfileAllColumns, + appUserProfilePrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := AppUserProfileSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testAppUserProfilesUpsert(t *testing.T) { + t.Parallel() + + if len(appUserProfileAllColumns) == len(appUserProfilePrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := AppUserProfile{} + if err = randomize.Struct(seed, &o, appUserProfileDBTypes, true); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert AppUserProfile: %s", err) + } + + count, err := AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, appUserProfileDBTypes, false, appUserProfilePrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert AppUserProfile: %s", err) + } + + count, err = AppUserProfiles().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/models/boil_main_test.go b/internal/models/boil_main_test.go new file mode 100644 index 0000000..6176d66 --- /dev/null +++ b/internal/models/boil_main_test.go @@ -0,0 +1,119 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "database/sql" + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/spf13/viper" +) + +var flagDebugMode = flag.Bool("test.sqldebug", false, "Turns on debug mode for SQL statements") +var flagConfigFile = flag.String("test.config", "", "Overrides the default config") + +const outputDirDepth = 2 + +var ( + dbMain tester +) + +type tester interface { + setup() error + conn() (*sql.DB, error) + teardown() error +} + +func TestMain(m *testing.M) { + if dbMain == nil { + fmt.Println("no dbMain tester interface was ready") + os.Exit(-1) + } + + rand.New(rand.NewSource(time.Now().UnixNano())) + + flag.Parse() + + var err error + + // Load configuration + err = initViper() + if err != nil { + fmt.Println("unable to load config file") + os.Exit(-2) + } + + // Set DebugMode so we can see generated sql statements + boil.DebugMode = *flagDebugMode + + if err = dbMain.setup(); err != nil { + fmt.Println("Unable to execute setup:", err) + os.Exit(-4) + } + + conn, err := dbMain.conn() + if err != nil { + fmt.Println("failed to get connection:", err) + } + + var code int + boil.SetDB(conn) + code = m.Run() + + if err = dbMain.teardown(); err != nil { + fmt.Println("Unable to execute teardown:", err) + os.Exit(-5) + } + + os.Exit(code) +} + +func initViper() error { + if flagConfigFile != nil && *flagConfigFile != "" { + viper.SetConfigFile(*flagConfigFile) + if err := viper.ReadInConfig(); err != nil { + return err + } + return nil + } + + var err error + + viper.SetConfigName("sqlboiler") + + configHome := os.Getenv("XDG_CONFIG_HOME") + homePath := os.Getenv("HOME") + wd, err := os.Getwd() + if err != nil { + wd = strings.Repeat("../", outputDirDepth) + } else { + wd = wd + strings.Repeat("/..", outputDirDepth) + } + + configPaths := []string{wd} + if len(configHome) > 0 { + configPaths = append(configPaths, filepath.Join(configHome, "sqlboiler")) + } else { + configPaths = append(configPaths, filepath.Join(homePath, ".config/sqlboiler")) + } + + for _, p := range configPaths { + viper.AddConfigPath(p) + } + + // Ignore errors here, fall back to defaults and validation to provide errs + _ = viper.ReadInConfig() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() + + return nil +} diff --git a/internal/models/boil_queries.go b/internal/models/boil_queries.go new file mode 100644 index 0000000..0259e69 --- /dev/null +++ b/internal/models/boil_queries.go @@ -0,0 +1,38 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "regexp" + + "github.com/aarondl/sqlboiler/v4/drivers" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +var dialect = drivers.Dialect{ + LQ: 0x22, + RQ: 0x22, + + UseIndexPlaceholders: true, + UseLastInsertID: false, + UseSchema: false, + UseDefaultKeyword: true, + UseAutoColumns: false, + UseTopClause: false, + UseOutputClause: false, + UseCaseWhenExistsClause: false, +} + +// This is a dummy variable to prevent unused regexp import error +var _ = ®exp.Regexp{} + +// NewQuery initializes a new Query using the passed in QueryMods +func NewQuery(mods ...qm.QueryMod) *queries.Query { + q := &queries.Query{} + queries.SetDialect(q, &dialect) + qm.Apply(q, mods...) + + return q +} diff --git a/internal/models/boil_queries_test.go b/internal/models/boil_queries_test.go new file mode 100644 index 0000000..d2bab84 --- /dev/null +++ b/internal/models/boil_queries_test.go @@ -0,0 +1,51 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "fmt" + "io" + "math/rand" + "regexp" + + "github.com/aarondl/sqlboiler/v4/boil" +) + +var dbNameRand *rand.Rand + +func MustTx(transactor boil.ContextTransactor, err error) boil.ContextTransactor { + if err != nil { + panic(fmt.Sprintf("Cannot create a transactor: %s", err)) + } + return transactor +} + +func newFKeyDestroyer(regex *regexp.Regexp, reader io.Reader) io.Reader { + return &fKeyDestroyer{ + reader: reader, + rgx: regex, + } +} + +type fKeyDestroyer struct { + reader io.Reader + buf *bytes.Buffer + rgx *regexp.Regexp +} + +func (f *fKeyDestroyer) Read(b []byte) (int, error) { + if f.buf == nil { + all, err := io.ReadAll(f.reader) + if err != nil { + return 0, err + } + + all = bytes.Replace(all, []byte{'\r', '\n'}, []byte{'\n'}, -1) + all = f.rgx.ReplaceAll(all, []byte{}) + f.buf = bytes.NewBuffer(all) + } + + return f.buf.Read(b) +} diff --git a/internal/models/boil_relationship_test.go b/internal/models/boil_relationship_test.go new file mode 100644 index 0000000..2e04a01 --- /dev/null +++ b/internal/models/boil_relationship_test.go @@ -0,0 +1,76 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import "testing" + +// TestToOne tests cannot be run in parallel +// or deadlocks can occur. +func TestToOne(t *testing.T) { + t.Run("AccessTokenToUserUsingUser", testAccessTokenToOneUserUsingUser) + t.Run("AppUserProfileToUserUsingUser", testAppUserProfileToOneUserUsingUser) + t.Run("ConfirmationTokenToUserUsingUser", testConfirmationTokenToOneUserUsingUser) + t.Run("PasswordResetTokenToUserUsingUser", testPasswordResetTokenToOneUserUsingUser) + t.Run("PushTokenToUserUsingUser", testPushTokenToOneUserUsingUser) + t.Run("RefreshTokenToUserUsingUser", testRefreshTokenToOneUserUsingUser) +} + +// TestOneToOne tests cannot be run in parallel +// or deadlocks can occur. +func TestOneToOne(t *testing.T) { + t.Run("UserToAppUserProfileUsingAppUserProfile", testUserOneToOneAppUserProfileUsingAppUserProfile) +} + +// TestToMany tests cannot be run in parallel +// or deadlocks can occur. +func TestToMany(t *testing.T) { + t.Run("UserToAccessTokens", testUserToManyAccessTokens) + t.Run("UserToConfirmationTokens", testUserToManyConfirmationTokens) + t.Run("UserToPasswordResetTokens", testUserToManyPasswordResetTokens) + t.Run("UserToPushTokens", testUserToManyPushTokens) + t.Run("UserToRefreshTokens", testUserToManyRefreshTokens) +} + +// TestToOneSet tests cannot be run in parallel +// or deadlocks can occur. +func TestToOneSet(t *testing.T) { + t.Run("AccessTokenToUserUsingAccessTokens", testAccessTokenToOneSetOpUserUsingUser) + t.Run("AppUserProfileToUserUsingAppUserProfile", testAppUserProfileToOneSetOpUserUsingUser) + t.Run("ConfirmationTokenToUserUsingConfirmationTokens", testConfirmationTokenToOneSetOpUserUsingUser) + t.Run("PasswordResetTokenToUserUsingPasswordResetTokens", testPasswordResetTokenToOneSetOpUserUsingUser) + t.Run("PushTokenToUserUsingPushTokens", testPushTokenToOneSetOpUserUsingUser) + t.Run("RefreshTokenToUserUsingRefreshTokens", testRefreshTokenToOneSetOpUserUsingUser) +} + +// TestToOneRemove tests cannot be run in parallel +// or deadlocks can occur. +func TestToOneRemove(t *testing.T) {} + +// TestOneToOneSet tests cannot be run in parallel +// or deadlocks can occur. +func TestOneToOneSet(t *testing.T) { + t.Run("UserToAppUserProfileUsingAppUserProfile", testUserOneToOneSetOpAppUserProfileUsingAppUserProfile) +} + +// TestOneToOneRemove tests cannot be run in parallel +// or deadlocks can occur. +func TestOneToOneRemove(t *testing.T) {} + +// TestToManyAdd tests cannot be run in parallel +// or deadlocks can occur. +func TestToManyAdd(t *testing.T) { + t.Run("UserToAccessTokens", testUserToManyAddOpAccessTokens) + t.Run("UserToConfirmationTokens", testUserToManyAddOpConfirmationTokens) + t.Run("UserToPasswordResetTokens", testUserToManyAddOpPasswordResetTokens) + t.Run("UserToPushTokens", testUserToManyAddOpPushTokens) + t.Run("UserToRefreshTokens", testUserToManyAddOpRefreshTokens) +} + +// TestToManySet tests cannot be run in parallel +// or deadlocks can occur. +func TestToManySet(t *testing.T) {} + +// TestToManyRemove tests cannot be run in parallel +// or deadlocks can occur. +func TestToManyRemove(t *testing.T) {} diff --git a/internal/models/boil_suites_test.go b/internal/models/boil_suites_test.go new file mode 100644 index 0000000..6a6f2c7 --- /dev/null +++ b/internal/models/boil_suites_test.go @@ -0,0 +1,179 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import "testing" + +// This test suite runs each operation test in parallel. +// Example, if your database has 3 tables, the suite will run: +// table1, table2 and table3 Delete in parallel +// table1, table2 and table3 Insert in parallel, and so forth. +// It does NOT run each operation group in parallel. +// Separating the tests thusly grants avoidance of Postgres deadlocks. +func TestParent(t *testing.T) { + t.Run("AccessTokens", testAccessTokens) + t.Run("AppUserProfiles", testAppUserProfiles) + t.Run("ConfirmationTokens", testConfirmationTokens) + t.Run("PasswordResetTokens", testPasswordResetTokens) + t.Run("PushTokens", testPushTokens) + t.Run("RefreshTokens", testRefreshTokens) + t.Run("Users", testUsers) +} + +func TestDelete(t *testing.T) { + t.Run("AccessTokens", testAccessTokensDelete) + t.Run("AppUserProfiles", testAppUserProfilesDelete) + t.Run("ConfirmationTokens", testConfirmationTokensDelete) + t.Run("PasswordResetTokens", testPasswordResetTokensDelete) + t.Run("PushTokens", testPushTokensDelete) + t.Run("RefreshTokens", testRefreshTokensDelete) + t.Run("Users", testUsersDelete) +} + +func TestQueryDeleteAll(t *testing.T) { + t.Run("AccessTokens", testAccessTokensQueryDeleteAll) + t.Run("AppUserProfiles", testAppUserProfilesQueryDeleteAll) + t.Run("ConfirmationTokens", testConfirmationTokensQueryDeleteAll) + t.Run("PasswordResetTokens", testPasswordResetTokensQueryDeleteAll) + t.Run("PushTokens", testPushTokensQueryDeleteAll) + t.Run("RefreshTokens", testRefreshTokensQueryDeleteAll) + t.Run("Users", testUsersQueryDeleteAll) +} + +func TestSliceDeleteAll(t *testing.T) { + t.Run("AccessTokens", testAccessTokensSliceDeleteAll) + t.Run("AppUserProfiles", testAppUserProfilesSliceDeleteAll) + t.Run("ConfirmationTokens", testConfirmationTokensSliceDeleteAll) + t.Run("PasswordResetTokens", testPasswordResetTokensSliceDeleteAll) + t.Run("PushTokens", testPushTokensSliceDeleteAll) + t.Run("RefreshTokens", testRefreshTokensSliceDeleteAll) + t.Run("Users", testUsersSliceDeleteAll) +} + +func TestExists(t *testing.T) { + t.Run("AccessTokens", testAccessTokensExists) + t.Run("AppUserProfiles", testAppUserProfilesExists) + t.Run("ConfirmationTokens", testConfirmationTokensExists) + t.Run("PasswordResetTokens", testPasswordResetTokensExists) + t.Run("PushTokens", testPushTokensExists) + t.Run("RefreshTokens", testRefreshTokensExists) + t.Run("Users", testUsersExists) +} + +func TestFind(t *testing.T) { + t.Run("AccessTokens", testAccessTokensFind) + t.Run("AppUserProfiles", testAppUserProfilesFind) + t.Run("ConfirmationTokens", testConfirmationTokensFind) + t.Run("PasswordResetTokens", testPasswordResetTokensFind) + t.Run("PushTokens", testPushTokensFind) + t.Run("RefreshTokens", testRefreshTokensFind) + t.Run("Users", testUsersFind) +} + +func TestBind(t *testing.T) { + t.Run("AccessTokens", testAccessTokensBind) + t.Run("AppUserProfiles", testAppUserProfilesBind) + t.Run("ConfirmationTokens", testConfirmationTokensBind) + t.Run("PasswordResetTokens", testPasswordResetTokensBind) + t.Run("PushTokens", testPushTokensBind) + t.Run("RefreshTokens", testRefreshTokensBind) + t.Run("Users", testUsersBind) +} + +func TestOne(t *testing.T) { + t.Run("AccessTokens", testAccessTokensOne) + t.Run("AppUserProfiles", testAppUserProfilesOne) + t.Run("ConfirmationTokens", testConfirmationTokensOne) + t.Run("PasswordResetTokens", testPasswordResetTokensOne) + t.Run("PushTokens", testPushTokensOne) + t.Run("RefreshTokens", testRefreshTokensOne) + t.Run("Users", testUsersOne) +} + +func TestAll(t *testing.T) { + t.Run("AccessTokens", testAccessTokensAll) + t.Run("AppUserProfiles", testAppUserProfilesAll) + t.Run("ConfirmationTokens", testConfirmationTokensAll) + t.Run("PasswordResetTokens", testPasswordResetTokensAll) + t.Run("PushTokens", testPushTokensAll) + t.Run("RefreshTokens", testRefreshTokensAll) + t.Run("Users", testUsersAll) +} + +func TestCount(t *testing.T) { + t.Run("AccessTokens", testAccessTokensCount) + t.Run("AppUserProfiles", testAppUserProfilesCount) + t.Run("ConfirmationTokens", testConfirmationTokensCount) + t.Run("PasswordResetTokens", testPasswordResetTokensCount) + t.Run("PushTokens", testPushTokensCount) + t.Run("RefreshTokens", testRefreshTokensCount) + t.Run("Users", testUsersCount) +} + +func TestInsert(t *testing.T) { + t.Run("AccessTokens", testAccessTokensInsert) + t.Run("AccessTokens", testAccessTokensInsertWhitelist) + t.Run("AppUserProfiles", testAppUserProfilesInsert) + t.Run("AppUserProfiles", testAppUserProfilesInsertWhitelist) + t.Run("ConfirmationTokens", testConfirmationTokensInsert) + t.Run("ConfirmationTokens", testConfirmationTokensInsertWhitelist) + t.Run("PasswordResetTokens", testPasswordResetTokensInsert) + t.Run("PasswordResetTokens", testPasswordResetTokensInsertWhitelist) + t.Run("PushTokens", testPushTokensInsert) + t.Run("PushTokens", testPushTokensInsertWhitelist) + t.Run("RefreshTokens", testRefreshTokensInsert) + t.Run("RefreshTokens", testRefreshTokensInsertWhitelist) + t.Run("Users", testUsersInsert) + t.Run("Users", testUsersInsertWhitelist) +} + +func TestReload(t *testing.T) { + t.Run("AccessTokens", testAccessTokensReload) + t.Run("AppUserProfiles", testAppUserProfilesReload) + t.Run("ConfirmationTokens", testConfirmationTokensReload) + t.Run("PasswordResetTokens", testPasswordResetTokensReload) + t.Run("PushTokens", testPushTokensReload) + t.Run("RefreshTokens", testRefreshTokensReload) + t.Run("Users", testUsersReload) +} + +func TestReloadAll(t *testing.T) { + t.Run("AccessTokens", testAccessTokensReloadAll) + t.Run("AppUserProfiles", testAppUserProfilesReloadAll) + t.Run("ConfirmationTokens", testConfirmationTokensReloadAll) + t.Run("PasswordResetTokens", testPasswordResetTokensReloadAll) + t.Run("PushTokens", testPushTokensReloadAll) + t.Run("RefreshTokens", testRefreshTokensReloadAll) + t.Run("Users", testUsersReloadAll) +} + +func TestSelect(t *testing.T) { + t.Run("AccessTokens", testAccessTokensSelect) + t.Run("AppUserProfiles", testAppUserProfilesSelect) + t.Run("ConfirmationTokens", testConfirmationTokensSelect) + t.Run("PasswordResetTokens", testPasswordResetTokensSelect) + t.Run("PushTokens", testPushTokensSelect) + t.Run("RefreshTokens", testRefreshTokensSelect) + t.Run("Users", testUsersSelect) +} + +func TestUpdate(t *testing.T) { + t.Run("AccessTokens", testAccessTokensUpdate) + t.Run("AppUserProfiles", testAppUserProfilesUpdate) + t.Run("ConfirmationTokens", testConfirmationTokensUpdate) + t.Run("PasswordResetTokens", testPasswordResetTokensUpdate) + t.Run("PushTokens", testPushTokensUpdate) + t.Run("RefreshTokens", testRefreshTokensUpdate) + t.Run("Users", testUsersUpdate) +} + +func TestSliceUpdateAll(t *testing.T) { + t.Run("AccessTokens", testAccessTokensSliceUpdateAll) + t.Run("AppUserProfiles", testAppUserProfilesSliceUpdateAll) + t.Run("ConfirmationTokens", testConfirmationTokensSliceUpdateAll) + t.Run("PasswordResetTokens", testPasswordResetTokensSliceUpdateAll) + t.Run("PushTokens", testPushTokensSliceUpdateAll) + t.Run("RefreshTokens", testRefreshTokensSliceUpdateAll) + t.Run("Users", testUsersSliceUpdateAll) +} diff --git a/internal/models/boil_table_names.go b/internal/models/boil_table_names.go new file mode 100644 index 0000000..d015f32 --- /dev/null +++ b/internal/models/boil_table_names.go @@ -0,0 +1,22 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +var TableNames = struct { + AccessTokens string + AppUserProfiles string + ConfirmationTokens string + PasswordResetTokens string + PushTokens string + RefreshTokens string + Users string +}{ + AccessTokens: "access_tokens", + AppUserProfiles: "app_user_profiles", + ConfirmationTokens: "confirmation_tokens", + PasswordResetTokens: "password_reset_tokens", + PushTokens: "push_tokens", + RefreshTokens: "refresh_tokens", + Users: "users", +} diff --git a/internal/models/boil_types.go b/internal/models/boil_types.go new file mode 100644 index 0000000..557965d --- /dev/null +++ b/internal/models/boil_types.go @@ -0,0 +1,65 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "strconv" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// M type is for providing columns and column values to UpdateAll. +type M map[string]interface{} + +// ErrSyncFail occurs during insert when the record could not be retrieved in +// order to populate default value information. This usually happens when LastInsertId +// fails or there was a primary key configuration that was not resolvable. +var ErrSyncFail = errors.New("models: failed to synchronize data after insert") + +type insertCache struct { + query string + retQuery string + valueMapping []uint64 + retMapping []uint64 +} + +type updateCache struct { + query string + valueMapping []uint64 +} + +func makeCacheKey(cols boil.Columns, nzDefaults []string) string { + buf := strmangle.GetBuffer() + + buf.WriteString(strconv.Itoa(cols.Kind)) + for _, w := range cols.Cols { + buf.WriteString(w) + } + + if len(nzDefaults) != 0 { + buf.WriteByte('.') + } + for _, nz := range nzDefaults { + buf.WriteString(nz) + } + + str := buf.String() + strmangle.PutBuffer(buf) + return str +} + +// Enum values for ProviderType +const ( + ProviderTypeFCM string = "fcm" + ProviderTypeApn string = "apn" +) + +func AllProviderType() []string { + return []string{ + ProviderTypeFCM, + ProviderTypeApn, + } +} diff --git a/internal/models/boil_view_names.go b/internal/models/boil_view_names.go new file mode 100644 index 0000000..0c19704 --- /dev/null +++ b/internal/models/boil_view_names.go @@ -0,0 +1,7 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +var ViewNames = struct { +}{} diff --git a/internal/models/confirmation_tokens.go b/internal/models/confirmation_tokens.go new file mode 100644 index 0000000..c7613ce --- /dev/null +++ b/internal/models/confirmation_tokens.go @@ -0,0 +1,910 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// ConfirmationToken is an object representing the database table. +type ConfirmationToken struct { + Token string `boil:"token" json:"token" toml:"token" yaml:"token"` + ValidUntil time.Time `boil:"valid_until" json:"valid_until" toml:"valid_until" yaml:"valid_until"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *confirmationTokenR `boil:"-" json:"-" toml:"-" yaml:"-"` + L confirmationTokenL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var ConfirmationTokenColumns = struct { + Token string + ValidUntil string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "token", + ValidUntil: "valid_until", + UserID: "user_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var ConfirmationTokenTableColumns = struct { + Token string + ValidUntil string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "confirmation_tokens.token", + ValidUntil: "confirmation_tokens.valid_until", + UserID: "confirmation_tokens.user_id", + CreatedAt: "confirmation_tokens.created_at", + UpdatedAt: "confirmation_tokens.updated_at", +} + +// Generated where + +var ConfirmationTokenWhere = struct { + Token whereHelperstring + ValidUntil whereHelpertime_Time + UserID whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + Token: whereHelperstring{field: "\"confirmation_tokens\".\"token\""}, + ValidUntil: whereHelpertime_Time{field: "\"confirmation_tokens\".\"valid_until\""}, + UserID: whereHelperstring{field: "\"confirmation_tokens\".\"user_id\""}, + CreatedAt: whereHelpertime_Time{field: "\"confirmation_tokens\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"confirmation_tokens\".\"updated_at\""}, +} + +// ConfirmationTokenRels is where relationship names are stored. +var ConfirmationTokenRels = struct { + User string +}{ + User: "User", +} + +// confirmationTokenR is where relationships are stored. +type confirmationTokenR struct { + User *User `boil:"User" json:"User" toml:"User" yaml:"User"` +} + +// NewStruct creates a new relationship struct +func (*confirmationTokenR) NewStruct() *confirmationTokenR { + return &confirmationTokenR{} +} + +func (o *ConfirmationToken) GetUser() *User { + if o == nil { + return nil + } + + return o.R.GetUser() +} + +func (r *confirmationTokenR) GetUser() *User { + if r == nil { + return nil + } + + return r.User +} + +// confirmationTokenL is where Load methods for each relationship are stored. +type confirmationTokenL struct{} + +var ( + confirmationTokenAllColumns = []string{"token", "valid_until", "user_id", "created_at", "updated_at"} + confirmationTokenColumnsWithoutDefault = []string{"valid_until", "user_id", "created_at", "updated_at"} + confirmationTokenColumnsWithDefault = []string{"token"} + confirmationTokenPrimaryKeyColumns = []string{"token"} + confirmationTokenGeneratedColumns = []string{} +) + +type ( + // ConfirmationTokenSlice is an alias for a slice of pointers to ConfirmationToken. + // This should almost always be used instead of []ConfirmationToken. + ConfirmationTokenSlice []*ConfirmationToken + + confirmationTokenQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + confirmationTokenType = reflect.TypeOf(&ConfirmationToken{}) + confirmationTokenMapping = queries.MakeStructMapping(confirmationTokenType) + confirmationTokenPrimaryKeyMapping, _ = queries.BindMapping(confirmationTokenType, confirmationTokenMapping, confirmationTokenPrimaryKeyColumns) + confirmationTokenInsertCacheMut sync.RWMutex + confirmationTokenInsertCache = make(map[string]insertCache) + confirmationTokenUpdateCacheMut sync.RWMutex + confirmationTokenUpdateCache = make(map[string]updateCache) + confirmationTokenUpsertCacheMut sync.RWMutex + confirmationTokenUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single confirmationToken record from the query. +func (q confirmationTokenQuery) One(ctx context.Context, exec boil.ContextExecutor) (*ConfirmationToken, error) { + o := &ConfirmationToken{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for confirmation_tokens") + } + + return o, nil +} + +// All returns all ConfirmationToken records from the query. +func (q confirmationTokenQuery) All(ctx context.Context, exec boil.ContextExecutor) (ConfirmationTokenSlice, error) { + var o []*ConfirmationToken + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to ConfirmationToken slice") + } + + return o, nil +} + +// Count returns the count of all ConfirmationToken records in the query. +func (q confirmationTokenQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count confirmation_tokens rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q confirmationTokenQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if confirmation_tokens exists") + } + + return count > 0, nil +} + +// User pointed to by the foreign key. +func (o *ConfirmationToken) User(mods ...qm.QueryMod) userQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.UserID), + } + + queryMods = append(queryMods, mods...) + + return Users(queryMods...) +} + +// LoadUser allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (confirmationTokenL) LoadUser(ctx context.Context, e boil.ContextExecutor, singular bool, maybeConfirmationToken interface{}, mods queries.Applicator) error { + var slice []*ConfirmationToken + var object *ConfirmationToken + + if singular { + var ok bool + object, ok = maybeConfirmationToken.(*ConfirmationToken) + if !ok { + object = new(ConfirmationToken) + ok = queries.SetFromEmbeddedStruct(&object, &maybeConfirmationToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeConfirmationToken)) + } + } + } else { + s, ok := maybeConfirmationToken.(*[]*ConfirmationToken) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeConfirmationToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeConfirmationToken)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &confirmationTokenR{} + } + args[object.UserID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &confirmationTokenR{} + } + + args[obj.UserID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`users`), + qm.WhereIn(`users.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load User") + } + + var resultSlice []*User + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice User") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for users") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for users") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.ConfirmationTokens = append(foreign.R.ConfirmationTokens, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.UserID == foreign.ID { + local.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.ConfirmationTokens = append(foreign.R.ConfirmationTokens, local) + break + } + } + } + + return nil +} + +// SetUser of the confirmationToken to the related item. +// Sets o.R.User to related. +// Adds o to related.R.ConfirmationTokens. +func (o *ConfirmationToken) SetUser(ctx context.Context, exec boil.ContextExecutor, insert bool, related *User) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"confirmation_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, confirmationTokenPrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.UserID = related.ID + if o.R == nil { + o.R = &confirmationTokenR{ + User: related, + } + } else { + o.R.User = related + } + + if related.R == nil { + related.R = &userR{ + ConfirmationTokens: ConfirmationTokenSlice{o}, + } + } else { + related.R.ConfirmationTokens = append(related.R.ConfirmationTokens, o) + } + + return nil +} + +// ConfirmationTokens retrieves all the records using an executor. +func ConfirmationTokens(mods ...qm.QueryMod) confirmationTokenQuery { + mods = append(mods, qm.From("\"confirmation_tokens\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"confirmation_tokens\".*"}) + } + + return confirmationTokenQuery{q} +} + +// FindConfirmationToken retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindConfirmationToken(ctx context.Context, exec boil.ContextExecutor, token string, selectCols ...string) (*ConfirmationToken, error) { + confirmationTokenObj := &ConfirmationToken{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"confirmation_tokens\" where \"token\"=$1", sel, + ) + + q := queries.Raw(query, token) + + err := q.Bind(ctx, exec, confirmationTokenObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from confirmation_tokens") + } + + return confirmationTokenObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *ConfirmationToken) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no confirmation_tokens provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(confirmationTokenColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + confirmationTokenInsertCacheMut.RLock() + cache, cached := confirmationTokenInsertCache[key] + confirmationTokenInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + confirmationTokenAllColumns, + confirmationTokenColumnsWithDefault, + confirmationTokenColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(confirmationTokenType, confirmationTokenMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(confirmationTokenType, confirmationTokenMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"confirmation_tokens\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"confirmation_tokens\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into confirmation_tokens") + } + + if !cached { + confirmationTokenInsertCacheMut.Lock() + confirmationTokenInsertCache[key] = cache + confirmationTokenInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the ConfirmationToken. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *ConfirmationToken) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + confirmationTokenUpdateCacheMut.RLock() + cache, cached := confirmationTokenUpdateCache[key] + confirmationTokenUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + confirmationTokenAllColumns, + confirmationTokenPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update confirmation_tokens, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"confirmation_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, confirmationTokenPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(confirmationTokenType, confirmationTokenMapping, append(wl, confirmationTokenPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update confirmation_tokens row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for confirmation_tokens") + } + + if !cached { + confirmationTokenUpdateCacheMut.Lock() + confirmationTokenUpdateCache[key] = cache + confirmationTokenUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q confirmationTokenQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for confirmation_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for confirmation_tokens") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o ConfirmationTokenSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), confirmationTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"confirmation_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, confirmationTokenPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in confirmationToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all confirmationToken") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *ConfirmationToken) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no confirmation_tokens provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(confirmationTokenColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + confirmationTokenUpsertCacheMut.RLock() + cache, cached := confirmationTokenUpsertCache[key] + confirmationTokenUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + confirmationTokenAllColumns, + confirmationTokenColumnsWithDefault, + confirmationTokenColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + confirmationTokenAllColumns, + confirmationTokenPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert confirmation_tokens, could not build update column list") + } + + ret := strmangle.SetComplement(confirmationTokenAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(confirmationTokenPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert confirmation_tokens, could not build conflict column list") + } + + conflict = make([]string, len(confirmationTokenPrimaryKeyColumns)) + copy(conflict, confirmationTokenPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"confirmation_tokens\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(confirmationTokenType, confirmationTokenMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(confirmationTokenType, confirmationTokenMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert confirmation_tokens") + } + + if !cached { + confirmationTokenUpsertCacheMut.Lock() + confirmationTokenUpsertCache[key] = cache + confirmationTokenUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single ConfirmationToken record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *ConfirmationToken) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no ConfirmationToken provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), confirmationTokenPrimaryKeyMapping) + sql := "DELETE FROM \"confirmation_tokens\" WHERE \"token\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from confirmation_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for confirmation_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q confirmationTokenQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no confirmationTokenQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from confirmation_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for confirmation_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o ConfirmationTokenSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), confirmationTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"confirmation_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, confirmationTokenPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from confirmationToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for confirmation_tokens") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *ConfirmationToken) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindConfirmationToken(ctx, exec, o.Token) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *ConfirmationTokenSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := ConfirmationTokenSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), confirmationTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"confirmation_tokens\".* FROM \"confirmation_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, confirmationTokenPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in ConfirmationTokenSlice") + } + + *o = slice + + return nil +} + +// ConfirmationTokenExists checks if the ConfirmationToken row exists. +func ConfirmationTokenExists(ctx context.Context, exec boil.ContextExecutor, token string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"confirmation_tokens\" where \"token\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, token) + } + row := exec.QueryRowContext(ctx, sql, token) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if confirmation_tokens exists") + } + + return exists, nil +} + +// Exists checks if the ConfirmationToken row exists. +func (o *ConfirmationToken) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return ConfirmationTokenExists(ctx, exec, o.Token) +} diff --git a/internal/models/confirmation_tokens_test.go b/internal/models/confirmation_tokens_test.go new file mode 100644 index 0000000..05b27a1 --- /dev/null +++ b/internal/models/confirmation_tokens_test.go @@ -0,0 +1,701 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testConfirmationTokens(t *testing.T) { + t.Parallel() + + query := ConfirmationTokens() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testConfirmationTokensDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testConfirmationTokensQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := ConfirmationTokens().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testConfirmationTokensSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := ConfirmationTokenSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testConfirmationTokensExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := ConfirmationTokenExists(ctx, tx, o.Token) + if err != nil { + t.Errorf("Unable to check if ConfirmationToken exists: %s", err) + } + if !e { + t.Errorf("Expected ConfirmationTokenExists to return true, but got false.") + } +} + +func testConfirmationTokensFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + confirmationTokenFound, err := FindConfirmationToken(ctx, tx, o.Token) + if err != nil { + t.Error(err) + } + + if confirmationTokenFound == nil { + t.Error("want a record, got nil") + } +} + +func testConfirmationTokensBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = ConfirmationTokens().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testConfirmationTokensOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := ConfirmationTokens().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testConfirmationTokensAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + confirmationTokenOne := &ConfirmationToken{} + confirmationTokenTwo := &ConfirmationToken{} + if err = randomize.Struct(seed, confirmationTokenOne, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + if err = randomize.Struct(seed, confirmationTokenTwo, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = confirmationTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = confirmationTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := ConfirmationTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testConfirmationTokensCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + confirmationTokenOne := &ConfirmationToken{} + confirmationTokenTwo := &ConfirmationToken{} + if err = randomize.Struct(seed, confirmationTokenOne, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + if err = randomize.Struct(seed, confirmationTokenTwo, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = confirmationTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = confirmationTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testConfirmationTokensInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testConfirmationTokensInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(confirmationTokenPrimaryKeyColumns, confirmationTokenColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testConfirmationTokenToOneUserUsingUser(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var local ConfirmationToken + var foreign User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &local, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + if err := randomize.Struct(seed, &foreign, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + local.UserID = foreign.ID + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.User().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.ID != foreign.ID { + t.Errorf("want: %v, got %v", foreign.ID, check.ID) + } + + slice := ConfirmationTokenSlice{&local} + if err = local.L.LoadUser(ctx, tx, false, (*[]*ConfirmationToken)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + + local.R.User = nil + if err = local.L.LoadUser(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testConfirmationTokenToOneSetOpUserUsingUser(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a ConfirmationToken + var b, c User + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, confirmationTokenDBTypes, false, strmangle.SetComplement(confirmationTokenPrimaryKeyColumns, confirmationTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*User{&b, &c} { + err = a.SetUser(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.User != x { + t.Error("relationship struct not set to correct value") + } + + if x.R.ConfirmationTokens[0] != &a { + t.Error("failed to append to foreign relationship struct") + } + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID) + } + + zero := reflect.Zero(reflect.TypeOf(a.UserID)) + reflect.Indirect(reflect.ValueOf(&a.UserID)).Set(zero) + + if err = a.Reload(ctx, tx); err != nil { + t.Fatal("failed to reload", err) + } + + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID, x.ID) + } + } +} + +func testConfirmationTokensReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testConfirmationTokensReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := ConfirmationTokenSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testConfirmationTokensSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := ConfirmationTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + confirmationTokenDBTypes = map[string]string{`Token`: `uuid`, `ValidUntil`: `timestamp with time zone`, `UserID`: `uuid`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`} + _ = bytes.MinRead +) + +func testConfirmationTokensUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(confirmationTokenPrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(confirmationTokenAllColumns) == len(confirmationTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testConfirmationTokensSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(confirmationTokenAllColumns) == len(confirmationTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &ConfirmationToken{} + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, confirmationTokenDBTypes, true, confirmationTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(confirmationTokenAllColumns, confirmationTokenPrimaryKeyColumns) { + fields = confirmationTokenAllColumns + } else { + fields = strmangle.SetComplement( + confirmationTokenAllColumns, + confirmationTokenPrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := ConfirmationTokenSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testConfirmationTokensUpsert(t *testing.T) { + t.Parallel() + + if len(confirmationTokenAllColumns) == len(confirmationTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := ConfirmationToken{} + if err = randomize.Struct(seed, &o, confirmationTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert ConfirmationToken: %s", err) + } + + count, err := ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, confirmationTokenDBTypes, false, confirmationTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize ConfirmationToken struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert ConfirmationToken: %s", err) + } + + count, err = ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/models/password_reset_tokens.go b/internal/models/password_reset_tokens.go new file mode 100644 index 0000000..3222d8a --- /dev/null +++ b/internal/models/password_reset_tokens.go @@ -0,0 +1,910 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// PasswordResetToken is an object representing the database table. +type PasswordResetToken struct { + Token string `boil:"token" json:"token" toml:"token" yaml:"token"` + ValidUntil time.Time `boil:"valid_until" json:"valid_until" toml:"valid_until" yaml:"valid_until"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *passwordResetTokenR `boil:"-" json:"-" toml:"-" yaml:"-"` + L passwordResetTokenL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var PasswordResetTokenColumns = struct { + Token string + ValidUntil string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "token", + ValidUntil: "valid_until", + UserID: "user_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var PasswordResetTokenTableColumns = struct { + Token string + ValidUntil string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "password_reset_tokens.token", + ValidUntil: "password_reset_tokens.valid_until", + UserID: "password_reset_tokens.user_id", + CreatedAt: "password_reset_tokens.created_at", + UpdatedAt: "password_reset_tokens.updated_at", +} + +// Generated where + +var PasswordResetTokenWhere = struct { + Token whereHelperstring + ValidUntil whereHelpertime_Time + UserID whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + Token: whereHelperstring{field: "\"password_reset_tokens\".\"token\""}, + ValidUntil: whereHelpertime_Time{field: "\"password_reset_tokens\".\"valid_until\""}, + UserID: whereHelperstring{field: "\"password_reset_tokens\".\"user_id\""}, + CreatedAt: whereHelpertime_Time{field: "\"password_reset_tokens\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"password_reset_tokens\".\"updated_at\""}, +} + +// PasswordResetTokenRels is where relationship names are stored. +var PasswordResetTokenRels = struct { + User string +}{ + User: "User", +} + +// passwordResetTokenR is where relationships are stored. +type passwordResetTokenR struct { + User *User `boil:"User" json:"User" toml:"User" yaml:"User"` +} + +// NewStruct creates a new relationship struct +func (*passwordResetTokenR) NewStruct() *passwordResetTokenR { + return &passwordResetTokenR{} +} + +func (o *PasswordResetToken) GetUser() *User { + if o == nil { + return nil + } + + return o.R.GetUser() +} + +func (r *passwordResetTokenR) GetUser() *User { + if r == nil { + return nil + } + + return r.User +} + +// passwordResetTokenL is where Load methods for each relationship are stored. +type passwordResetTokenL struct{} + +var ( + passwordResetTokenAllColumns = []string{"token", "valid_until", "user_id", "created_at", "updated_at"} + passwordResetTokenColumnsWithoutDefault = []string{"valid_until", "user_id", "created_at", "updated_at"} + passwordResetTokenColumnsWithDefault = []string{"token"} + passwordResetTokenPrimaryKeyColumns = []string{"token"} + passwordResetTokenGeneratedColumns = []string{} +) + +type ( + // PasswordResetTokenSlice is an alias for a slice of pointers to PasswordResetToken. + // This should almost always be used instead of []PasswordResetToken. + PasswordResetTokenSlice []*PasswordResetToken + + passwordResetTokenQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + passwordResetTokenType = reflect.TypeOf(&PasswordResetToken{}) + passwordResetTokenMapping = queries.MakeStructMapping(passwordResetTokenType) + passwordResetTokenPrimaryKeyMapping, _ = queries.BindMapping(passwordResetTokenType, passwordResetTokenMapping, passwordResetTokenPrimaryKeyColumns) + passwordResetTokenInsertCacheMut sync.RWMutex + passwordResetTokenInsertCache = make(map[string]insertCache) + passwordResetTokenUpdateCacheMut sync.RWMutex + passwordResetTokenUpdateCache = make(map[string]updateCache) + passwordResetTokenUpsertCacheMut sync.RWMutex + passwordResetTokenUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single passwordResetToken record from the query. +func (q passwordResetTokenQuery) One(ctx context.Context, exec boil.ContextExecutor) (*PasswordResetToken, error) { + o := &PasswordResetToken{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for password_reset_tokens") + } + + return o, nil +} + +// All returns all PasswordResetToken records from the query. +func (q passwordResetTokenQuery) All(ctx context.Context, exec boil.ContextExecutor) (PasswordResetTokenSlice, error) { + var o []*PasswordResetToken + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to PasswordResetToken slice") + } + + return o, nil +} + +// Count returns the count of all PasswordResetToken records in the query. +func (q passwordResetTokenQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count password_reset_tokens rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q passwordResetTokenQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if password_reset_tokens exists") + } + + return count > 0, nil +} + +// User pointed to by the foreign key. +func (o *PasswordResetToken) User(mods ...qm.QueryMod) userQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.UserID), + } + + queryMods = append(queryMods, mods...) + + return Users(queryMods...) +} + +// LoadUser allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (passwordResetTokenL) LoadUser(ctx context.Context, e boil.ContextExecutor, singular bool, maybePasswordResetToken interface{}, mods queries.Applicator) error { + var slice []*PasswordResetToken + var object *PasswordResetToken + + if singular { + var ok bool + object, ok = maybePasswordResetToken.(*PasswordResetToken) + if !ok { + object = new(PasswordResetToken) + ok = queries.SetFromEmbeddedStruct(&object, &maybePasswordResetToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybePasswordResetToken)) + } + } + } else { + s, ok := maybePasswordResetToken.(*[]*PasswordResetToken) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybePasswordResetToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybePasswordResetToken)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &passwordResetTokenR{} + } + args[object.UserID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &passwordResetTokenR{} + } + + args[obj.UserID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`users`), + qm.WhereIn(`users.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load User") + } + + var resultSlice []*User + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice User") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for users") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for users") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.PasswordResetTokens = append(foreign.R.PasswordResetTokens, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.UserID == foreign.ID { + local.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.PasswordResetTokens = append(foreign.R.PasswordResetTokens, local) + break + } + } + } + + return nil +} + +// SetUser of the passwordResetToken to the related item. +// Sets o.R.User to related. +// Adds o to related.R.PasswordResetTokens. +func (o *PasswordResetToken) SetUser(ctx context.Context, exec boil.ContextExecutor, insert bool, related *User) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"password_reset_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, passwordResetTokenPrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.UserID = related.ID + if o.R == nil { + o.R = &passwordResetTokenR{ + User: related, + } + } else { + o.R.User = related + } + + if related.R == nil { + related.R = &userR{ + PasswordResetTokens: PasswordResetTokenSlice{o}, + } + } else { + related.R.PasswordResetTokens = append(related.R.PasswordResetTokens, o) + } + + return nil +} + +// PasswordResetTokens retrieves all the records using an executor. +func PasswordResetTokens(mods ...qm.QueryMod) passwordResetTokenQuery { + mods = append(mods, qm.From("\"password_reset_tokens\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"password_reset_tokens\".*"}) + } + + return passwordResetTokenQuery{q} +} + +// FindPasswordResetToken retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindPasswordResetToken(ctx context.Context, exec boil.ContextExecutor, token string, selectCols ...string) (*PasswordResetToken, error) { + passwordResetTokenObj := &PasswordResetToken{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"password_reset_tokens\" where \"token\"=$1", sel, + ) + + q := queries.Raw(query, token) + + err := q.Bind(ctx, exec, passwordResetTokenObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from password_reset_tokens") + } + + return passwordResetTokenObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *PasswordResetToken) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no password_reset_tokens provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(passwordResetTokenColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + passwordResetTokenInsertCacheMut.RLock() + cache, cached := passwordResetTokenInsertCache[key] + passwordResetTokenInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + passwordResetTokenAllColumns, + passwordResetTokenColumnsWithDefault, + passwordResetTokenColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(passwordResetTokenType, passwordResetTokenMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(passwordResetTokenType, passwordResetTokenMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"password_reset_tokens\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"password_reset_tokens\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into password_reset_tokens") + } + + if !cached { + passwordResetTokenInsertCacheMut.Lock() + passwordResetTokenInsertCache[key] = cache + passwordResetTokenInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the PasswordResetToken. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *PasswordResetToken) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + passwordResetTokenUpdateCacheMut.RLock() + cache, cached := passwordResetTokenUpdateCache[key] + passwordResetTokenUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + passwordResetTokenAllColumns, + passwordResetTokenPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update password_reset_tokens, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"password_reset_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, passwordResetTokenPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(passwordResetTokenType, passwordResetTokenMapping, append(wl, passwordResetTokenPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update password_reset_tokens row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for password_reset_tokens") + } + + if !cached { + passwordResetTokenUpdateCacheMut.Lock() + passwordResetTokenUpdateCache[key] = cache + passwordResetTokenUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q passwordResetTokenQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for password_reset_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for password_reset_tokens") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o PasswordResetTokenSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), passwordResetTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"password_reset_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, passwordResetTokenPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in passwordResetToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all passwordResetToken") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *PasswordResetToken) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no password_reset_tokens provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(passwordResetTokenColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + passwordResetTokenUpsertCacheMut.RLock() + cache, cached := passwordResetTokenUpsertCache[key] + passwordResetTokenUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + passwordResetTokenAllColumns, + passwordResetTokenColumnsWithDefault, + passwordResetTokenColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + passwordResetTokenAllColumns, + passwordResetTokenPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert password_reset_tokens, could not build update column list") + } + + ret := strmangle.SetComplement(passwordResetTokenAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(passwordResetTokenPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert password_reset_tokens, could not build conflict column list") + } + + conflict = make([]string, len(passwordResetTokenPrimaryKeyColumns)) + copy(conflict, passwordResetTokenPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"password_reset_tokens\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(passwordResetTokenType, passwordResetTokenMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(passwordResetTokenType, passwordResetTokenMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert password_reset_tokens") + } + + if !cached { + passwordResetTokenUpsertCacheMut.Lock() + passwordResetTokenUpsertCache[key] = cache + passwordResetTokenUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single PasswordResetToken record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *PasswordResetToken) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no PasswordResetToken provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), passwordResetTokenPrimaryKeyMapping) + sql := "DELETE FROM \"password_reset_tokens\" WHERE \"token\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from password_reset_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for password_reset_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q passwordResetTokenQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no passwordResetTokenQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from password_reset_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for password_reset_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o PasswordResetTokenSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), passwordResetTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"password_reset_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, passwordResetTokenPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from passwordResetToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for password_reset_tokens") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *PasswordResetToken) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindPasswordResetToken(ctx, exec, o.Token) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *PasswordResetTokenSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := PasswordResetTokenSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), passwordResetTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"password_reset_tokens\".* FROM \"password_reset_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, passwordResetTokenPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in PasswordResetTokenSlice") + } + + *o = slice + + return nil +} + +// PasswordResetTokenExists checks if the PasswordResetToken row exists. +func PasswordResetTokenExists(ctx context.Context, exec boil.ContextExecutor, token string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"password_reset_tokens\" where \"token\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, token) + } + row := exec.QueryRowContext(ctx, sql, token) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if password_reset_tokens exists") + } + + return exists, nil +} + +// Exists checks if the PasswordResetToken row exists. +func (o *PasswordResetToken) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return PasswordResetTokenExists(ctx, exec, o.Token) +} diff --git a/internal/models/password_reset_tokens_test.go b/internal/models/password_reset_tokens_test.go new file mode 100644 index 0000000..59d170a --- /dev/null +++ b/internal/models/password_reset_tokens_test.go @@ -0,0 +1,701 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testPasswordResetTokens(t *testing.T) { + t.Parallel() + + query := PasswordResetTokens() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testPasswordResetTokensDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testPasswordResetTokensQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := PasswordResetTokens().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testPasswordResetTokensSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := PasswordResetTokenSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testPasswordResetTokensExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := PasswordResetTokenExists(ctx, tx, o.Token) + if err != nil { + t.Errorf("Unable to check if PasswordResetToken exists: %s", err) + } + if !e { + t.Errorf("Expected PasswordResetTokenExists to return true, but got false.") + } +} + +func testPasswordResetTokensFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + passwordResetTokenFound, err := FindPasswordResetToken(ctx, tx, o.Token) + if err != nil { + t.Error(err) + } + + if passwordResetTokenFound == nil { + t.Error("want a record, got nil") + } +} + +func testPasswordResetTokensBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = PasswordResetTokens().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testPasswordResetTokensOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := PasswordResetTokens().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testPasswordResetTokensAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + passwordResetTokenOne := &PasswordResetToken{} + passwordResetTokenTwo := &PasswordResetToken{} + if err = randomize.Struct(seed, passwordResetTokenOne, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + if err = randomize.Struct(seed, passwordResetTokenTwo, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = passwordResetTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = passwordResetTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := PasswordResetTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testPasswordResetTokensCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + passwordResetTokenOne := &PasswordResetToken{} + passwordResetTokenTwo := &PasswordResetToken{} + if err = randomize.Struct(seed, passwordResetTokenOne, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + if err = randomize.Struct(seed, passwordResetTokenTwo, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = passwordResetTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = passwordResetTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testPasswordResetTokensInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testPasswordResetTokensInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(passwordResetTokenPrimaryKeyColumns, passwordResetTokenColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testPasswordResetTokenToOneUserUsingUser(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var local PasswordResetToken + var foreign User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &local, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + if err := randomize.Struct(seed, &foreign, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + local.UserID = foreign.ID + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.User().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.ID != foreign.ID { + t.Errorf("want: %v, got %v", foreign.ID, check.ID) + } + + slice := PasswordResetTokenSlice{&local} + if err = local.L.LoadUser(ctx, tx, false, (*[]*PasswordResetToken)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + + local.R.User = nil + if err = local.L.LoadUser(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testPasswordResetTokenToOneSetOpUserUsingUser(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a PasswordResetToken + var b, c User + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, passwordResetTokenDBTypes, false, strmangle.SetComplement(passwordResetTokenPrimaryKeyColumns, passwordResetTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*User{&b, &c} { + err = a.SetUser(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.User != x { + t.Error("relationship struct not set to correct value") + } + + if x.R.PasswordResetTokens[0] != &a { + t.Error("failed to append to foreign relationship struct") + } + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID) + } + + zero := reflect.Zero(reflect.TypeOf(a.UserID)) + reflect.Indirect(reflect.ValueOf(&a.UserID)).Set(zero) + + if err = a.Reload(ctx, tx); err != nil { + t.Fatal("failed to reload", err) + } + + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID, x.ID) + } + } +} + +func testPasswordResetTokensReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testPasswordResetTokensReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := PasswordResetTokenSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testPasswordResetTokensSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := PasswordResetTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + passwordResetTokenDBTypes = map[string]string{`Token`: `uuid`, `ValidUntil`: `timestamp with time zone`, `UserID`: `uuid`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`} + _ = bytes.MinRead +) + +func testPasswordResetTokensUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(passwordResetTokenPrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(passwordResetTokenAllColumns) == len(passwordResetTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testPasswordResetTokensSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(passwordResetTokenAllColumns) == len(passwordResetTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &PasswordResetToken{} + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, passwordResetTokenDBTypes, true, passwordResetTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(passwordResetTokenAllColumns, passwordResetTokenPrimaryKeyColumns) { + fields = passwordResetTokenAllColumns + } else { + fields = strmangle.SetComplement( + passwordResetTokenAllColumns, + passwordResetTokenPrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := PasswordResetTokenSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testPasswordResetTokensUpsert(t *testing.T) { + t.Parallel() + + if len(passwordResetTokenAllColumns) == len(passwordResetTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := PasswordResetToken{} + if err = randomize.Struct(seed, &o, passwordResetTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert PasswordResetToken: %s", err) + } + + count, err := PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, passwordResetTokenDBTypes, false, passwordResetTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize PasswordResetToken struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert PasswordResetToken: %s", err) + } + + count, err = PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/models/psql_main_test.go b/internal/models/psql_main_test.go new file mode 100644 index 0000000..f00783c --- /dev/null +++ b/internal/models/psql_main_test.go @@ -0,0 +1,231 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "database/sql" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/drivers/sqlboiler-psql/driver" + "github.com/friendsofgo/errors" + "github.com/kat-co/vala" + _ "github.com/lib/pq" + "github.com/spf13/viper" +) + +var rgxPGFkey = regexp.MustCompile(`(?m)^ALTER TABLE .*\n\s+ADD CONSTRAINT .*? FOREIGN KEY .*?;\n`) + +type pgTester struct { + dbConn *sql.DB + + dbName string + host string + user string + pass string + sslmode string + port int + + pgPassFile string + + testDBName string + skipSQLCmd bool +} + +func init() { + dbMain = &pgTester{} +} + +// setup dumps the database schema and imports it into a temporary randomly +// generated test database so that tests can be run against it using the +// generated sqlboiler ORM package. +func (p *pgTester) setup() error { + var err error + + viper.SetDefault("psql.schema", "public") + viper.SetDefault("psql.port", 5432) + viper.SetDefault("psql.sslmode", "require") + + p.dbName = viper.GetString("psql.dbname") + p.host = viper.GetString("psql.host") + p.user = viper.GetString("psql.user") + p.pass = viper.GetString("psql.pass") + p.port = viper.GetInt("psql.port") + p.sslmode = viper.GetString("psql.sslmode") + p.testDBName = viper.GetString("psql.testdbname") + p.skipSQLCmd = viper.GetBool("psql.skipsqlcmd") + + err = vala.BeginValidation().Validate( + vala.StringNotEmpty(p.user, "psql.user"), + vala.StringNotEmpty(p.host, "psql.host"), + vala.Not(vala.Equals(p.port, 0, "psql.port")), + vala.StringNotEmpty(p.dbName, "psql.dbname"), + vala.StringNotEmpty(p.sslmode, "psql.sslmode"), + ).Check() + + if err != nil { + return err + } + + // if no testing DB passed + if len(p.testDBName) == 0 { + // Create a randomized db name. + p.testDBName = randomize.StableDBName(p.dbName) + } + + if err = p.makePGPassFile(); err != nil { + return err + } + + if !p.skipSQLCmd { + if err = p.dropTestDB(); err != nil { + return err + } + if err = p.createTestDB(); err != nil { + return err + } + + dumpCmd := exec.Command("pg_dump", "--schema-only", p.dbName) + dumpCmd.Env = append(os.Environ(), p.pgEnv()...) + createCmd := exec.Command("psql", p.testDBName) + createCmd.Env = append(os.Environ(), p.pgEnv()...) + + r, w := io.Pipe() + dumpCmdStderr := &bytes.Buffer{} + createCmdStderr := &bytes.Buffer{} + + dumpCmd.Stdout = w + dumpCmd.Stderr = dumpCmdStderr + + createCmd.Stdin = newFKeyDestroyer(rgxPGFkey, r) + createCmd.Stderr = createCmdStderr + + if err = dumpCmd.Start(); err != nil { + return errors.Wrap(err, "failed to start pg_dump command") + } + if err = createCmd.Start(); err != nil { + return errors.Wrap(err, "failed to start psql command") + } + + if err = dumpCmd.Wait(); err != nil { + fmt.Println(err) + fmt.Println(dumpCmdStderr.String()) + return errors.Wrap(err, "failed to wait for pg_dump command") + } + + _ = w.Close() // After dumpCmd is done, close the write end of the pipe + + if err = createCmd.Wait(); err != nil { + fmt.Println(err) + fmt.Println(createCmdStderr.String()) + return errors.Wrap(err, "failed to wait for psql command") + } + } + + return nil +} + +func (p *pgTester) runCmd(stdin, command string, args ...string) error { + cmd := exec.Command(command, args...) + cmd.Env = append(os.Environ(), p.pgEnv()...) + + if len(stdin) != 0 { + cmd.Stdin = strings.NewReader(stdin) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + fmt.Println("failed running:", command, args) + fmt.Println(stdout.String()) + fmt.Println(stderr.String()) + return err + } + + return nil +} + +func (p *pgTester) pgEnv() []string { + return []string{ + fmt.Sprintf("PGHOST=%s", p.host), + fmt.Sprintf("PGPORT=%d", p.port), + fmt.Sprintf("PGUSER=%s", p.user), + fmt.Sprintf("PGPASSFILE=%s", p.pgPassFile), + } +} + +func (p *pgTester) makePGPassFile() error { + tmp, err := os.CreateTemp("", "pgpass") + if err != nil { + return errors.Wrap(err, "failed to create option file") + } + + fmt.Fprintf(tmp, "%s:%d:postgres:%s", p.host, p.port, p.user) + if len(p.pass) != 0 { + fmt.Fprintf(tmp, ":%s", p.pass) + } + fmt.Fprintln(tmp) + + fmt.Fprintf(tmp, "%s:%d:%s:%s", p.host, p.port, p.dbName, p.user) + if len(p.pass) != 0 { + fmt.Fprintf(tmp, ":%s", p.pass) + } + fmt.Fprintln(tmp) + + fmt.Fprintf(tmp, "%s:%d:%s:%s", p.host, p.port, p.testDBName, p.user) + if len(p.pass) != 0 { + fmt.Fprintf(tmp, ":%s", p.pass) + } + fmt.Fprintln(tmp) + + p.pgPassFile = tmp.Name() + return tmp.Close() +} + +func (p *pgTester) createTestDB() error { + return p.runCmd("", "createdb", p.testDBName) +} + +func (p *pgTester) dropTestDB() error { + return p.runCmd("", "dropdb", "--if-exists", p.testDBName) +} + +// teardown executes cleanup tasks when the tests finish running +func (p *pgTester) teardown() error { + var err error + if err = p.dbConn.Close(); err != nil { + return err + } + p.dbConn = nil + + if !p.skipSQLCmd { + if err = p.dropTestDB(); err != nil { + return err + } + } + + return os.Remove(p.pgPassFile) +} + +func (p *pgTester) conn() (*sql.DB, error) { + if p.dbConn != nil { + return p.dbConn, nil + } + + var err error + p.dbConn, err = sql.Open("postgres", driver.PSQLBuildQueryString(p.user, p.pass, p.testDBName, p.host, p.port, p.sslmode)) + if err != nil { + return nil, err + } + + return p.dbConn, nil +} diff --git a/internal/models/psql_suites_test.go b/internal/models/psql_suites_test.go new file mode 100644 index 0000000..6918cfe --- /dev/null +++ b/internal/models/psql_suites_test.go @@ -0,0 +1,22 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import "testing" + +func TestUpsert(t *testing.T) { + t.Run("AccessTokens", testAccessTokensUpsert) + + t.Run("AppUserProfiles", testAppUserProfilesUpsert) + + t.Run("ConfirmationTokens", testConfirmationTokensUpsert) + + t.Run("PasswordResetTokens", testPasswordResetTokensUpsert) + + t.Run("PushTokens", testPushTokensUpsert) + + t.Run("RefreshTokens", testRefreshTokensUpsert) + + t.Run("Users", testUsersUpsert) +} diff --git a/internal/models/psql_upsert.go b/internal/models/psql_upsert.go new file mode 100644 index 0000000..b1e116f --- /dev/null +++ b/internal/models/psql_upsert.go @@ -0,0 +1,99 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "fmt" + "strings" + + "github.com/aarondl/sqlboiler/v4/drivers" + "github.com/aarondl/strmangle" +) + +type UpsertOptions struct { + conflictTarget string + updateSet string +} + +type UpsertOptionFunc func(o *UpsertOptions) + +func UpsertConflictTarget(conflictTarget string) UpsertOptionFunc { + return func(o *UpsertOptions) { + o.conflictTarget = conflictTarget + } +} + +func UpsertUpdateSet(updateSet string) UpsertOptionFunc { + return func(o *UpsertOptions) { + o.updateSet = updateSet + } +} + +// buildUpsertQueryPostgres builds a SQL statement string using the upsertData provided. +func buildUpsertQueryPostgres(dia drivers.Dialect, tableName string, updateOnConflict bool, ret, update, conflict, whitelist []string, opts ...UpsertOptionFunc) string { + conflict = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, conflict) + whitelist = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, whitelist) + ret = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, ret) + + upsertOpts := &UpsertOptions{} + for _, o := range opts { + o(upsertOpts) + } + + buf := strmangle.GetBuffer() + defer strmangle.PutBuffer(buf) + + columns := "DEFAULT VALUES" + if len(whitelist) != 0 { + columns = fmt.Sprintf("(%s) VALUES (%s)", + strings.Join(whitelist, ", "), + strmangle.Placeholders(dia.UseIndexPlaceholders, len(whitelist), 1, 1)) + } + + fmt.Fprintf( + buf, + "INSERT INTO %s %s ON CONFLICT ", + tableName, + columns, + ) + + if upsertOpts.conflictTarget != "" { + buf.WriteString(upsertOpts.conflictTarget) + } else if len(conflict) != 0 { + buf.WriteByte('(') + buf.WriteString(strings.Join(conflict, ", ")) + buf.WriteByte(')') + } + buf.WriteByte(' ') + + if !updateOnConflict || len(update) == 0 { + buf.WriteString("DO NOTHING") + } else { + buf.WriteString("DO UPDATE SET ") + + if upsertOpts.updateSet != "" { + buf.WriteString(upsertOpts.updateSet) + } else { + for i, v := range update { + if len(v) == 0 { + continue + } + if i != 0 { + buf.WriteByte(',') + } + quoted := strmangle.IdentQuote(dia.LQ, dia.RQ, v) + buf.WriteString(quoted) + buf.WriteString(" = EXCLUDED.") + buf.WriteString(quoted) + } + } + } + + if len(ret) != 0 { + buf.WriteString(" RETURNING ") + buf.WriteString(strings.Join(ret, ", ")) + } + + return buf.String() +} diff --git a/internal/models/push_tokens.go b/internal/models/push_tokens.go new file mode 100644 index 0000000..5879fc3 --- /dev/null +++ b/internal/models/push_tokens.go @@ -0,0 +1,917 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// PushToken is an object representing the database table. +type PushToken struct { + ID string `boil:"id" json:"id" toml:"id" yaml:"id"` + Token string `boil:"token" json:"token" toml:"token" yaml:"token"` + Provider string `boil:"provider" json:"provider" toml:"provider" yaml:"provider"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *pushTokenR `boil:"-" json:"-" toml:"-" yaml:"-"` + L pushTokenL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var PushTokenColumns = struct { + ID string + Token string + Provider string + UserID string + CreatedAt string + UpdatedAt string +}{ + ID: "id", + Token: "token", + Provider: "provider", + UserID: "user_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var PushTokenTableColumns = struct { + ID string + Token string + Provider string + UserID string + CreatedAt string + UpdatedAt string +}{ + ID: "push_tokens.id", + Token: "push_tokens.token", + Provider: "push_tokens.provider", + UserID: "push_tokens.user_id", + CreatedAt: "push_tokens.created_at", + UpdatedAt: "push_tokens.updated_at", +} + +// Generated where + +var PushTokenWhere = struct { + ID whereHelperstring + Token whereHelperstring + Provider whereHelperstring + UserID whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + ID: whereHelperstring{field: "\"push_tokens\".\"id\""}, + Token: whereHelperstring{field: "\"push_tokens\".\"token\""}, + Provider: whereHelperstring{field: "\"push_tokens\".\"provider\""}, + UserID: whereHelperstring{field: "\"push_tokens\".\"user_id\""}, + CreatedAt: whereHelpertime_Time{field: "\"push_tokens\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"push_tokens\".\"updated_at\""}, +} + +// PushTokenRels is where relationship names are stored. +var PushTokenRels = struct { + User string +}{ + User: "User", +} + +// pushTokenR is where relationships are stored. +type pushTokenR struct { + User *User `boil:"User" json:"User" toml:"User" yaml:"User"` +} + +// NewStruct creates a new relationship struct +func (*pushTokenR) NewStruct() *pushTokenR { + return &pushTokenR{} +} + +func (o *PushToken) GetUser() *User { + if o == nil { + return nil + } + + return o.R.GetUser() +} + +func (r *pushTokenR) GetUser() *User { + if r == nil { + return nil + } + + return r.User +} + +// pushTokenL is where Load methods for each relationship are stored. +type pushTokenL struct{} + +var ( + pushTokenAllColumns = []string{"id", "token", "provider", "user_id", "created_at", "updated_at"} + pushTokenColumnsWithoutDefault = []string{"token", "provider", "user_id", "created_at", "updated_at"} + pushTokenColumnsWithDefault = []string{"id"} + pushTokenPrimaryKeyColumns = []string{"id"} + pushTokenGeneratedColumns = []string{} +) + +type ( + // PushTokenSlice is an alias for a slice of pointers to PushToken. + // This should almost always be used instead of []PushToken. + PushTokenSlice []*PushToken + + pushTokenQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + pushTokenType = reflect.TypeOf(&PushToken{}) + pushTokenMapping = queries.MakeStructMapping(pushTokenType) + pushTokenPrimaryKeyMapping, _ = queries.BindMapping(pushTokenType, pushTokenMapping, pushTokenPrimaryKeyColumns) + pushTokenInsertCacheMut sync.RWMutex + pushTokenInsertCache = make(map[string]insertCache) + pushTokenUpdateCacheMut sync.RWMutex + pushTokenUpdateCache = make(map[string]updateCache) + pushTokenUpsertCacheMut sync.RWMutex + pushTokenUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single pushToken record from the query. +func (q pushTokenQuery) One(ctx context.Context, exec boil.ContextExecutor) (*PushToken, error) { + o := &PushToken{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for push_tokens") + } + + return o, nil +} + +// All returns all PushToken records from the query. +func (q pushTokenQuery) All(ctx context.Context, exec boil.ContextExecutor) (PushTokenSlice, error) { + var o []*PushToken + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to PushToken slice") + } + + return o, nil +} + +// Count returns the count of all PushToken records in the query. +func (q pushTokenQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count push_tokens rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q pushTokenQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if push_tokens exists") + } + + return count > 0, nil +} + +// User pointed to by the foreign key. +func (o *PushToken) User(mods ...qm.QueryMod) userQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.UserID), + } + + queryMods = append(queryMods, mods...) + + return Users(queryMods...) +} + +// LoadUser allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (pushTokenL) LoadUser(ctx context.Context, e boil.ContextExecutor, singular bool, maybePushToken interface{}, mods queries.Applicator) error { + var slice []*PushToken + var object *PushToken + + if singular { + var ok bool + object, ok = maybePushToken.(*PushToken) + if !ok { + object = new(PushToken) + ok = queries.SetFromEmbeddedStruct(&object, &maybePushToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybePushToken)) + } + } + } else { + s, ok := maybePushToken.(*[]*PushToken) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybePushToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybePushToken)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &pushTokenR{} + } + args[object.UserID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &pushTokenR{} + } + + args[obj.UserID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`users`), + qm.WhereIn(`users.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load User") + } + + var resultSlice []*User + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice User") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for users") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for users") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.PushTokens = append(foreign.R.PushTokens, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.UserID == foreign.ID { + local.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.PushTokens = append(foreign.R.PushTokens, local) + break + } + } + } + + return nil +} + +// SetUser of the pushToken to the related item. +// Sets o.R.User to related. +// Adds o to related.R.PushTokens. +func (o *PushToken) SetUser(ctx context.Context, exec boil.ContextExecutor, insert bool, related *User) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"push_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, pushTokenPrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.ID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.UserID = related.ID + if o.R == nil { + o.R = &pushTokenR{ + User: related, + } + } else { + o.R.User = related + } + + if related.R == nil { + related.R = &userR{ + PushTokens: PushTokenSlice{o}, + } + } else { + related.R.PushTokens = append(related.R.PushTokens, o) + } + + return nil +} + +// PushTokens retrieves all the records using an executor. +func PushTokens(mods ...qm.QueryMod) pushTokenQuery { + mods = append(mods, qm.From("\"push_tokens\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"push_tokens\".*"}) + } + + return pushTokenQuery{q} +} + +// FindPushToken retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindPushToken(ctx context.Context, exec boil.ContextExecutor, iD string, selectCols ...string) (*PushToken, error) { + pushTokenObj := &PushToken{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"push_tokens\" where \"id\"=$1", sel, + ) + + q := queries.Raw(query, iD) + + err := q.Bind(ctx, exec, pushTokenObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from push_tokens") + } + + return pushTokenObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *PushToken) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no push_tokens provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(pushTokenColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + pushTokenInsertCacheMut.RLock() + cache, cached := pushTokenInsertCache[key] + pushTokenInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + pushTokenAllColumns, + pushTokenColumnsWithDefault, + pushTokenColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(pushTokenType, pushTokenMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(pushTokenType, pushTokenMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"push_tokens\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"push_tokens\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into push_tokens") + } + + if !cached { + pushTokenInsertCacheMut.Lock() + pushTokenInsertCache[key] = cache + pushTokenInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the PushToken. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *PushToken) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + pushTokenUpdateCacheMut.RLock() + cache, cached := pushTokenUpdateCache[key] + pushTokenUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + pushTokenAllColumns, + pushTokenPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update push_tokens, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"push_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, pushTokenPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(pushTokenType, pushTokenMapping, append(wl, pushTokenPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update push_tokens row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for push_tokens") + } + + if !cached { + pushTokenUpdateCacheMut.Lock() + pushTokenUpdateCache[key] = cache + pushTokenUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q pushTokenQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for push_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for push_tokens") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o PushTokenSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), pushTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"push_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, pushTokenPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in pushToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all pushToken") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *PushToken) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no push_tokens provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(pushTokenColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + pushTokenUpsertCacheMut.RLock() + cache, cached := pushTokenUpsertCache[key] + pushTokenUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + pushTokenAllColumns, + pushTokenColumnsWithDefault, + pushTokenColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + pushTokenAllColumns, + pushTokenPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert push_tokens, could not build update column list") + } + + ret := strmangle.SetComplement(pushTokenAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(pushTokenPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert push_tokens, could not build conflict column list") + } + + conflict = make([]string, len(pushTokenPrimaryKeyColumns)) + copy(conflict, pushTokenPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"push_tokens\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(pushTokenType, pushTokenMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(pushTokenType, pushTokenMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert push_tokens") + } + + if !cached { + pushTokenUpsertCacheMut.Lock() + pushTokenUpsertCache[key] = cache + pushTokenUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single PushToken record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *PushToken) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no PushToken provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), pushTokenPrimaryKeyMapping) + sql := "DELETE FROM \"push_tokens\" WHERE \"id\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from push_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for push_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q pushTokenQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no pushTokenQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from push_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for push_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o PushTokenSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), pushTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"push_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, pushTokenPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from pushToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for push_tokens") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *PushToken) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindPushToken(ctx, exec, o.ID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *PushTokenSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := PushTokenSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), pushTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"push_tokens\".* FROM \"push_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, pushTokenPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in PushTokenSlice") + } + + *o = slice + + return nil +} + +// PushTokenExists checks if the PushToken row exists. +func PushTokenExists(ctx context.Context, exec boil.ContextExecutor, iD string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"push_tokens\" where \"id\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, iD) + } + row := exec.QueryRowContext(ctx, sql, iD) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if push_tokens exists") + } + + return exists, nil +} + +// Exists checks if the PushToken row exists. +func (o *PushToken) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return PushTokenExists(ctx, exec, o.ID) +} diff --git a/internal/models/push_tokens_test.go b/internal/models/push_tokens_test.go new file mode 100644 index 0000000..9fb62c5 --- /dev/null +++ b/internal/models/push_tokens_test.go @@ -0,0 +1,701 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testPushTokens(t *testing.T) { + t.Parallel() + + query := PushTokens() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testPushTokensDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testPushTokensQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := PushTokens().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testPushTokensSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := PushTokenSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testPushTokensExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := PushTokenExists(ctx, tx, o.ID) + if err != nil { + t.Errorf("Unable to check if PushToken exists: %s", err) + } + if !e { + t.Errorf("Expected PushTokenExists to return true, but got false.") + } +} + +func testPushTokensFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + pushTokenFound, err := FindPushToken(ctx, tx, o.ID) + if err != nil { + t.Error(err) + } + + if pushTokenFound == nil { + t.Error("want a record, got nil") + } +} + +func testPushTokensBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = PushTokens().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testPushTokensOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := PushTokens().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testPushTokensAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + pushTokenOne := &PushToken{} + pushTokenTwo := &PushToken{} + if err = randomize.Struct(seed, pushTokenOne, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + if err = randomize.Struct(seed, pushTokenTwo, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = pushTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = pushTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := PushTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testPushTokensCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + pushTokenOne := &PushToken{} + pushTokenTwo := &PushToken{} + if err = randomize.Struct(seed, pushTokenOne, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + if err = randomize.Struct(seed, pushTokenTwo, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = pushTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = pushTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testPushTokensInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testPushTokensInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(pushTokenPrimaryKeyColumns, pushTokenColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testPushTokenToOneUserUsingUser(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var local PushToken + var foreign User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &local, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + if err := randomize.Struct(seed, &foreign, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + local.UserID = foreign.ID + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.User().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.ID != foreign.ID { + t.Errorf("want: %v, got %v", foreign.ID, check.ID) + } + + slice := PushTokenSlice{&local} + if err = local.L.LoadUser(ctx, tx, false, (*[]*PushToken)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + + local.R.User = nil + if err = local.L.LoadUser(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testPushTokenToOneSetOpUserUsingUser(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a PushToken + var b, c User + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, pushTokenDBTypes, false, strmangle.SetComplement(pushTokenPrimaryKeyColumns, pushTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*User{&b, &c} { + err = a.SetUser(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.User != x { + t.Error("relationship struct not set to correct value") + } + + if x.R.PushTokens[0] != &a { + t.Error("failed to append to foreign relationship struct") + } + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID) + } + + zero := reflect.Zero(reflect.TypeOf(a.UserID)) + reflect.Indirect(reflect.ValueOf(&a.UserID)).Set(zero) + + if err = a.Reload(ctx, tx); err != nil { + t.Fatal("failed to reload", err) + } + + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID, x.ID) + } + } +} + +func testPushTokensReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testPushTokensReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := PushTokenSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testPushTokensSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := PushTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + pushTokenDBTypes = map[string]string{`ID`: `uuid`, `Token`: `text`, `Provider`: `enum.provider_type('fcm','apn')`, `UserID`: `uuid`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`} + _ = bytes.MinRead +) + +func testPushTokensUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(pushTokenPrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(pushTokenAllColumns) == len(pushTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testPushTokensSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(pushTokenAllColumns) == len(pushTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &PushToken{} + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, pushTokenDBTypes, true, pushTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(pushTokenAllColumns, pushTokenPrimaryKeyColumns) { + fields = pushTokenAllColumns + } else { + fields = strmangle.SetComplement( + pushTokenAllColumns, + pushTokenPrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := PushTokenSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testPushTokensUpsert(t *testing.T) { + t.Parallel() + + if len(pushTokenAllColumns) == len(pushTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := PushToken{} + if err = randomize.Struct(seed, &o, pushTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert PushToken: %s", err) + } + + count, err := PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, pushTokenDBTypes, false, pushTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize PushToken struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert PushToken: %s", err) + } + + count, err = PushTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/models/refresh_tokens.go b/internal/models/refresh_tokens.go new file mode 100644 index 0000000..c6be794 --- /dev/null +++ b/internal/models/refresh_tokens.go @@ -0,0 +1,903 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// RefreshToken is an object representing the database table. +type RefreshToken struct { + Token string `boil:"token" json:"token" toml:"token" yaml:"token"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *refreshTokenR `boil:"-" json:"-" toml:"-" yaml:"-"` + L refreshTokenL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var RefreshTokenColumns = struct { + Token string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "token", + UserID: "user_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var RefreshTokenTableColumns = struct { + Token string + UserID string + CreatedAt string + UpdatedAt string +}{ + Token: "refresh_tokens.token", + UserID: "refresh_tokens.user_id", + CreatedAt: "refresh_tokens.created_at", + UpdatedAt: "refresh_tokens.updated_at", +} + +// Generated where + +var RefreshTokenWhere = struct { + Token whereHelperstring + UserID whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + Token: whereHelperstring{field: "\"refresh_tokens\".\"token\""}, + UserID: whereHelperstring{field: "\"refresh_tokens\".\"user_id\""}, + CreatedAt: whereHelpertime_Time{field: "\"refresh_tokens\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"refresh_tokens\".\"updated_at\""}, +} + +// RefreshTokenRels is where relationship names are stored. +var RefreshTokenRels = struct { + User string +}{ + User: "User", +} + +// refreshTokenR is where relationships are stored. +type refreshTokenR struct { + User *User `boil:"User" json:"User" toml:"User" yaml:"User"` +} + +// NewStruct creates a new relationship struct +func (*refreshTokenR) NewStruct() *refreshTokenR { + return &refreshTokenR{} +} + +func (o *RefreshToken) GetUser() *User { + if o == nil { + return nil + } + + return o.R.GetUser() +} + +func (r *refreshTokenR) GetUser() *User { + if r == nil { + return nil + } + + return r.User +} + +// refreshTokenL is where Load methods for each relationship are stored. +type refreshTokenL struct{} + +var ( + refreshTokenAllColumns = []string{"token", "user_id", "created_at", "updated_at"} + refreshTokenColumnsWithoutDefault = []string{"user_id", "created_at", "updated_at"} + refreshTokenColumnsWithDefault = []string{"token"} + refreshTokenPrimaryKeyColumns = []string{"token"} + refreshTokenGeneratedColumns = []string{} +) + +type ( + // RefreshTokenSlice is an alias for a slice of pointers to RefreshToken. + // This should almost always be used instead of []RefreshToken. + RefreshTokenSlice []*RefreshToken + + refreshTokenQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + refreshTokenType = reflect.TypeOf(&RefreshToken{}) + refreshTokenMapping = queries.MakeStructMapping(refreshTokenType) + refreshTokenPrimaryKeyMapping, _ = queries.BindMapping(refreshTokenType, refreshTokenMapping, refreshTokenPrimaryKeyColumns) + refreshTokenInsertCacheMut sync.RWMutex + refreshTokenInsertCache = make(map[string]insertCache) + refreshTokenUpdateCacheMut sync.RWMutex + refreshTokenUpdateCache = make(map[string]updateCache) + refreshTokenUpsertCacheMut sync.RWMutex + refreshTokenUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single refreshToken record from the query. +func (q refreshTokenQuery) One(ctx context.Context, exec boil.ContextExecutor) (*RefreshToken, error) { + o := &RefreshToken{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for refresh_tokens") + } + + return o, nil +} + +// All returns all RefreshToken records from the query. +func (q refreshTokenQuery) All(ctx context.Context, exec boil.ContextExecutor) (RefreshTokenSlice, error) { + var o []*RefreshToken + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to RefreshToken slice") + } + + return o, nil +} + +// Count returns the count of all RefreshToken records in the query. +func (q refreshTokenQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count refresh_tokens rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q refreshTokenQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if refresh_tokens exists") + } + + return count > 0, nil +} + +// User pointed to by the foreign key. +func (o *RefreshToken) User(mods ...qm.QueryMod) userQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.UserID), + } + + queryMods = append(queryMods, mods...) + + return Users(queryMods...) +} + +// LoadUser allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (refreshTokenL) LoadUser(ctx context.Context, e boil.ContextExecutor, singular bool, maybeRefreshToken interface{}, mods queries.Applicator) error { + var slice []*RefreshToken + var object *RefreshToken + + if singular { + var ok bool + object, ok = maybeRefreshToken.(*RefreshToken) + if !ok { + object = new(RefreshToken) + ok = queries.SetFromEmbeddedStruct(&object, &maybeRefreshToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeRefreshToken)) + } + } + } else { + s, ok := maybeRefreshToken.(*[]*RefreshToken) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeRefreshToken) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeRefreshToken)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &refreshTokenR{} + } + args[object.UserID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &refreshTokenR{} + } + + args[obj.UserID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`users`), + qm.WhereIn(`users.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load User") + } + + var resultSlice []*User + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice User") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for users") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for users") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.RefreshTokens = append(foreign.R.RefreshTokens, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.UserID == foreign.ID { + local.R.User = foreign + if foreign.R == nil { + foreign.R = &userR{} + } + foreign.R.RefreshTokens = append(foreign.R.RefreshTokens, local) + break + } + } + } + + return nil +} + +// SetUser of the refreshToken to the related item. +// Sets o.R.User to related. +// Adds o to related.R.RefreshTokens. +func (o *RefreshToken) SetUser(ctx context.Context, exec boil.ContextExecutor, insert bool, related *User) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"refresh_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, refreshTokenPrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.UserID = related.ID + if o.R == nil { + o.R = &refreshTokenR{ + User: related, + } + } else { + o.R.User = related + } + + if related.R == nil { + related.R = &userR{ + RefreshTokens: RefreshTokenSlice{o}, + } + } else { + related.R.RefreshTokens = append(related.R.RefreshTokens, o) + } + + return nil +} + +// RefreshTokens retrieves all the records using an executor. +func RefreshTokens(mods ...qm.QueryMod) refreshTokenQuery { + mods = append(mods, qm.From("\"refresh_tokens\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"refresh_tokens\".*"}) + } + + return refreshTokenQuery{q} +} + +// FindRefreshToken retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindRefreshToken(ctx context.Context, exec boil.ContextExecutor, token string, selectCols ...string) (*RefreshToken, error) { + refreshTokenObj := &RefreshToken{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"refresh_tokens\" where \"token\"=$1", sel, + ) + + q := queries.Raw(query, token) + + err := q.Bind(ctx, exec, refreshTokenObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from refresh_tokens") + } + + return refreshTokenObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *RefreshToken) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no refresh_tokens provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(refreshTokenColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + refreshTokenInsertCacheMut.RLock() + cache, cached := refreshTokenInsertCache[key] + refreshTokenInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + refreshTokenAllColumns, + refreshTokenColumnsWithDefault, + refreshTokenColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(refreshTokenType, refreshTokenMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(refreshTokenType, refreshTokenMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"refresh_tokens\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"refresh_tokens\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into refresh_tokens") + } + + if !cached { + refreshTokenInsertCacheMut.Lock() + refreshTokenInsertCache[key] = cache + refreshTokenInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the RefreshToken. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *RefreshToken) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + refreshTokenUpdateCacheMut.RLock() + cache, cached := refreshTokenUpdateCache[key] + refreshTokenUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + refreshTokenAllColumns, + refreshTokenPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update refresh_tokens, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"refresh_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, refreshTokenPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(refreshTokenType, refreshTokenMapping, append(wl, refreshTokenPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update refresh_tokens row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for refresh_tokens") + } + + if !cached { + refreshTokenUpdateCacheMut.Lock() + refreshTokenUpdateCache[key] = cache + refreshTokenUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q refreshTokenQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for refresh_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for refresh_tokens") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o RefreshTokenSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), refreshTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"refresh_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, refreshTokenPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in refreshToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all refreshToken") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *RefreshToken) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no refresh_tokens provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(refreshTokenColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + refreshTokenUpsertCacheMut.RLock() + cache, cached := refreshTokenUpsertCache[key] + refreshTokenUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + refreshTokenAllColumns, + refreshTokenColumnsWithDefault, + refreshTokenColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + refreshTokenAllColumns, + refreshTokenPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert refresh_tokens, could not build update column list") + } + + ret := strmangle.SetComplement(refreshTokenAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(refreshTokenPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert refresh_tokens, could not build conflict column list") + } + + conflict = make([]string, len(refreshTokenPrimaryKeyColumns)) + copy(conflict, refreshTokenPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"refresh_tokens\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(refreshTokenType, refreshTokenMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(refreshTokenType, refreshTokenMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert refresh_tokens") + } + + if !cached { + refreshTokenUpsertCacheMut.Lock() + refreshTokenUpsertCache[key] = cache + refreshTokenUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single RefreshToken record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *RefreshToken) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no RefreshToken provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), refreshTokenPrimaryKeyMapping) + sql := "DELETE FROM \"refresh_tokens\" WHERE \"token\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from refresh_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for refresh_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q refreshTokenQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no refreshTokenQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from refresh_tokens") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for refresh_tokens") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o RefreshTokenSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), refreshTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"refresh_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, refreshTokenPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from refreshToken slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for refresh_tokens") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *RefreshToken) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindRefreshToken(ctx, exec, o.Token) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *RefreshTokenSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := RefreshTokenSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), refreshTokenPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"refresh_tokens\".* FROM \"refresh_tokens\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, refreshTokenPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in RefreshTokenSlice") + } + + *o = slice + + return nil +} + +// RefreshTokenExists checks if the RefreshToken row exists. +func RefreshTokenExists(ctx context.Context, exec boil.ContextExecutor, token string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"refresh_tokens\" where \"token\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, token) + } + row := exec.QueryRowContext(ctx, sql, token) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if refresh_tokens exists") + } + + return exists, nil +} + +// Exists checks if the RefreshToken row exists. +func (o *RefreshToken) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return RefreshTokenExists(ctx, exec, o.Token) +} diff --git a/internal/models/refresh_tokens_test.go b/internal/models/refresh_tokens_test.go new file mode 100644 index 0000000..6cbe0bd --- /dev/null +++ b/internal/models/refresh_tokens_test.go @@ -0,0 +1,701 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testRefreshTokens(t *testing.T) { + t.Parallel() + + query := RefreshTokens() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testRefreshTokensDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testRefreshTokensQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := RefreshTokens().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testRefreshTokensSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := RefreshTokenSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testRefreshTokensExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := RefreshTokenExists(ctx, tx, o.Token) + if err != nil { + t.Errorf("Unable to check if RefreshToken exists: %s", err) + } + if !e { + t.Errorf("Expected RefreshTokenExists to return true, but got false.") + } +} + +func testRefreshTokensFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + refreshTokenFound, err := FindRefreshToken(ctx, tx, o.Token) + if err != nil { + t.Error(err) + } + + if refreshTokenFound == nil { + t.Error("want a record, got nil") + } +} + +func testRefreshTokensBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = RefreshTokens().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testRefreshTokensOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := RefreshTokens().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testRefreshTokensAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + refreshTokenOne := &RefreshToken{} + refreshTokenTwo := &RefreshToken{} + if err = randomize.Struct(seed, refreshTokenOne, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + if err = randomize.Struct(seed, refreshTokenTwo, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = refreshTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = refreshTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := RefreshTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testRefreshTokensCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + refreshTokenOne := &RefreshToken{} + refreshTokenTwo := &RefreshToken{} + if err = randomize.Struct(seed, refreshTokenOne, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + if err = randomize.Struct(seed, refreshTokenTwo, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = refreshTokenOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = refreshTokenTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testRefreshTokensInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testRefreshTokensInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(refreshTokenPrimaryKeyColumns, refreshTokenColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testRefreshTokenToOneUserUsingUser(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var local RefreshToken + var foreign User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &local, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + if err := randomize.Struct(seed, &foreign, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + local.UserID = foreign.ID + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.User().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.ID != foreign.ID { + t.Errorf("want: %v, got %v", foreign.ID, check.ID) + } + + slice := RefreshTokenSlice{&local} + if err = local.L.LoadUser(ctx, tx, false, (*[]*RefreshToken)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + + local.R.User = nil + if err = local.L.LoadUser(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.User == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testRefreshTokenToOneSetOpUserUsingUser(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a RefreshToken + var b, c User + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, refreshTokenDBTypes, false, strmangle.SetComplement(refreshTokenPrimaryKeyColumns, refreshTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*User{&b, &c} { + err = a.SetUser(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.User != x { + t.Error("relationship struct not set to correct value") + } + + if x.R.RefreshTokens[0] != &a { + t.Error("failed to append to foreign relationship struct") + } + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID) + } + + zero := reflect.Zero(reflect.TypeOf(a.UserID)) + reflect.Indirect(reflect.ValueOf(&a.UserID)).Set(zero) + + if err = a.Reload(ctx, tx); err != nil { + t.Fatal("failed to reload", err) + } + + if a.UserID != x.ID { + t.Error("foreign key was wrong value", a.UserID, x.ID) + } + } +} + +func testRefreshTokensReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testRefreshTokensReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := RefreshTokenSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testRefreshTokensSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := RefreshTokens().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + refreshTokenDBTypes = map[string]string{`Token`: `uuid`, `UserID`: `uuid`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`} + _ = bytes.MinRead +) + +func testRefreshTokensUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(refreshTokenPrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(refreshTokenAllColumns) == len(refreshTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testRefreshTokensSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(refreshTokenAllColumns) == len(refreshTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &RefreshToken{} + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, refreshTokenDBTypes, true, refreshTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(refreshTokenAllColumns, refreshTokenPrimaryKeyColumns) { + fields = refreshTokenAllColumns + } else { + fields = strmangle.SetComplement( + refreshTokenAllColumns, + refreshTokenPrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := RefreshTokenSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testRefreshTokensUpsert(t *testing.T) { + t.Parallel() + + if len(refreshTokenAllColumns) == len(refreshTokenPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := RefreshToken{} + if err = randomize.Struct(seed, &o, refreshTokenDBTypes, true); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert RefreshToken: %s", err) + } + + count, err := RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, refreshTokenDBTypes, false, refreshTokenPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize RefreshToken struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert RefreshToken: %s", err) + } + + count, err = RefreshTokens().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/models/users.go b/internal/models/users.go new file mode 100644 index 0000000..0ac2a41 --- /dev/null +++ b/internal/models/users.go @@ -0,0 +1,1986 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/sqlboiler/v4/types" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// User is an object representing the database table. +type User struct { + ID string `boil:"id" json:"id" toml:"id" yaml:"id"` + Username null.String `boil:"username" json:"username,omitempty" toml:"username" yaml:"username,omitempty"` + Password null.String `boil:"password" json:"password,omitempty" toml:"password" yaml:"password,omitempty"` + IsActive bool `boil:"is_active" json:"is_active" toml:"is_active" yaml:"is_active"` + Scopes types.StringArray `boil:"scopes" json:"scopes" toml:"scopes" yaml:"scopes"` + LastAuthenticatedAt null.Time `boil:"last_authenticated_at" json:"last_authenticated_at,omitempty" toml:"last_authenticated_at" yaml:"last_authenticated_at,omitempty"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + RequiresConfirmation bool `boil:"requires_confirmation" json:"requires_confirmation" toml:"requires_confirmation" yaml:"requires_confirmation"` + + R *userR `boil:"-" json:"-" toml:"-" yaml:"-"` + L userL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var UserColumns = struct { + ID string + Username string + Password string + IsActive string + Scopes string + LastAuthenticatedAt string + CreatedAt string + UpdatedAt string + RequiresConfirmation string +}{ + ID: "id", + Username: "username", + Password: "password", + IsActive: "is_active", + Scopes: "scopes", + LastAuthenticatedAt: "last_authenticated_at", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + RequiresConfirmation: "requires_confirmation", +} + +var UserTableColumns = struct { + ID string + Username string + Password string + IsActive string + Scopes string + LastAuthenticatedAt string + CreatedAt string + UpdatedAt string + RequiresConfirmation string +}{ + ID: "users.id", + Username: "users.username", + Password: "users.password", + IsActive: "users.is_active", + Scopes: "users.scopes", + LastAuthenticatedAt: "users.last_authenticated_at", + CreatedAt: "users.created_at", + UpdatedAt: "users.updated_at", + RequiresConfirmation: "users.requires_confirmation", +} + +// Generated where + +type whereHelpernull_String struct{ field string } + +func (w whereHelpernull_String) EQ(x null.String) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, false, x) +} +func (w whereHelpernull_String) NEQ(x null.String) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, true, x) +} +func (w whereHelpernull_String) LT(x null.String) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpernull_String) LTE(x null.String) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpernull_String) GT(x null.String) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpernull_String) GTE(x null.String) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} +func (w whereHelpernull_String) LIKE(x null.String) qm.QueryMod { + return qm.Where(w.field+" LIKE ?", x) +} +func (w whereHelpernull_String) NLIKE(x null.String) qm.QueryMod { + return qm.Where(w.field+" NOT LIKE ?", x) +} +func (w whereHelpernull_String) ILIKE(x null.String) qm.QueryMod { + return qm.Where(w.field+" ILIKE ?", x) +} +func (w whereHelpernull_String) NILIKE(x null.String) qm.QueryMod { + return qm.Where(w.field+" NOT ILIKE ?", x) +} +func (w whereHelpernull_String) SIMILAR(x null.String) qm.QueryMod { + return qm.Where(w.field+" SIMILAR TO ?", x) +} +func (w whereHelpernull_String) NSIMILAR(x null.String) qm.QueryMod { + return qm.Where(w.field+" NOT SIMILAR TO ?", x) +} +func (w whereHelpernull_String) IN(slice []string) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...) +} +func (w whereHelpernull_String) NIN(slice []string) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...) +} + +func (w whereHelpernull_String) IsNull() qm.QueryMod { return qmhelper.WhereIsNull(w.field) } +func (w whereHelpernull_String) IsNotNull() qm.QueryMod { return qmhelper.WhereIsNotNull(w.field) } + +type whereHelperbool struct{ field string } + +func (w whereHelperbool) EQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperbool) NEQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperbool) LT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperbool) LTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } + +type whereHelpertypes_StringArray struct{ field string } + +func (w whereHelpertypes_StringArray) EQ(x types.StringArray) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.EQ, x) +} +func (w whereHelpertypes_StringArray) NEQ(x types.StringArray) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.NEQ, x) +} +func (w whereHelpertypes_StringArray) LT(x types.StringArray) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpertypes_StringArray) LTE(x types.StringArray) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpertypes_StringArray) GT(x types.StringArray) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpertypes_StringArray) GTE(x types.StringArray) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +var UserWhere = struct { + ID whereHelperstring + Username whereHelpernull_String + Password whereHelpernull_String + IsActive whereHelperbool + Scopes whereHelpertypes_StringArray + LastAuthenticatedAt whereHelpernull_Time + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time + RequiresConfirmation whereHelperbool +}{ + ID: whereHelperstring{field: "\"users\".\"id\""}, + Username: whereHelpernull_String{field: "\"users\".\"username\""}, + Password: whereHelpernull_String{field: "\"users\".\"password\""}, + IsActive: whereHelperbool{field: "\"users\".\"is_active\""}, + Scopes: whereHelpertypes_StringArray{field: "\"users\".\"scopes\""}, + LastAuthenticatedAt: whereHelpernull_Time{field: "\"users\".\"last_authenticated_at\""}, + CreatedAt: whereHelpertime_Time{field: "\"users\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"users\".\"updated_at\""}, + RequiresConfirmation: whereHelperbool{field: "\"users\".\"requires_confirmation\""}, +} + +// UserRels is where relationship names are stored. +var UserRels = struct { + AppUserProfile string + AccessTokens string + ConfirmationTokens string + PasswordResetTokens string + PushTokens string + RefreshTokens string +}{ + AppUserProfile: "AppUserProfile", + AccessTokens: "AccessTokens", + ConfirmationTokens: "ConfirmationTokens", + PasswordResetTokens: "PasswordResetTokens", + PushTokens: "PushTokens", + RefreshTokens: "RefreshTokens", +} + +// userR is where relationships are stored. +type userR struct { + AppUserProfile *AppUserProfile `boil:"AppUserProfile" json:"AppUserProfile" toml:"AppUserProfile" yaml:"AppUserProfile"` + AccessTokens AccessTokenSlice `boil:"AccessTokens" json:"AccessTokens" toml:"AccessTokens" yaml:"AccessTokens"` + ConfirmationTokens ConfirmationTokenSlice `boil:"ConfirmationTokens" json:"ConfirmationTokens" toml:"ConfirmationTokens" yaml:"ConfirmationTokens"` + PasswordResetTokens PasswordResetTokenSlice `boil:"PasswordResetTokens" json:"PasswordResetTokens" toml:"PasswordResetTokens" yaml:"PasswordResetTokens"` + PushTokens PushTokenSlice `boil:"PushTokens" json:"PushTokens" toml:"PushTokens" yaml:"PushTokens"` + RefreshTokens RefreshTokenSlice `boil:"RefreshTokens" json:"RefreshTokens" toml:"RefreshTokens" yaml:"RefreshTokens"` +} + +// NewStruct creates a new relationship struct +func (*userR) NewStruct() *userR { + return &userR{} +} + +func (o *User) GetAppUserProfile() *AppUserProfile { + if o == nil { + return nil + } + + return o.R.GetAppUserProfile() +} + +func (r *userR) GetAppUserProfile() *AppUserProfile { + if r == nil { + return nil + } + + return r.AppUserProfile +} + +func (o *User) GetAccessTokens() AccessTokenSlice { + if o == nil { + return nil + } + + return o.R.GetAccessTokens() +} + +func (r *userR) GetAccessTokens() AccessTokenSlice { + if r == nil { + return nil + } + + return r.AccessTokens +} + +func (o *User) GetConfirmationTokens() ConfirmationTokenSlice { + if o == nil { + return nil + } + + return o.R.GetConfirmationTokens() +} + +func (r *userR) GetConfirmationTokens() ConfirmationTokenSlice { + if r == nil { + return nil + } + + return r.ConfirmationTokens +} + +func (o *User) GetPasswordResetTokens() PasswordResetTokenSlice { + if o == nil { + return nil + } + + return o.R.GetPasswordResetTokens() +} + +func (r *userR) GetPasswordResetTokens() PasswordResetTokenSlice { + if r == nil { + return nil + } + + return r.PasswordResetTokens +} + +func (o *User) GetPushTokens() PushTokenSlice { + if o == nil { + return nil + } + + return o.R.GetPushTokens() +} + +func (r *userR) GetPushTokens() PushTokenSlice { + if r == nil { + return nil + } + + return r.PushTokens +} + +func (o *User) GetRefreshTokens() RefreshTokenSlice { + if o == nil { + return nil + } + + return o.R.GetRefreshTokens() +} + +func (r *userR) GetRefreshTokens() RefreshTokenSlice { + if r == nil { + return nil + } + + return r.RefreshTokens +} + +// userL is where Load methods for each relationship are stored. +type userL struct{} + +var ( + userAllColumns = []string{"id", "username", "password", "is_active", "scopes", "last_authenticated_at", "created_at", "updated_at", "requires_confirmation"} + userColumnsWithoutDefault = []string{"is_active", "scopes", "created_at", "updated_at"} + userColumnsWithDefault = []string{"id", "username", "password", "last_authenticated_at", "requires_confirmation"} + userPrimaryKeyColumns = []string{"id"} + userGeneratedColumns = []string{} +) + +type ( + // UserSlice is an alias for a slice of pointers to User. + // This should almost always be used instead of []User. + UserSlice []*User + + userQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + userType = reflect.TypeOf(&User{}) + userMapping = queries.MakeStructMapping(userType) + userPrimaryKeyMapping, _ = queries.BindMapping(userType, userMapping, userPrimaryKeyColumns) + userInsertCacheMut sync.RWMutex + userInsertCache = make(map[string]insertCache) + userUpdateCacheMut sync.RWMutex + userUpdateCache = make(map[string]updateCache) + userUpsertCacheMut sync.RWMutex + userUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single user record from the query. +func (q userQuery) One(ctx context.Context, exec boil.ContextExecutor) (*User, error) { + o := &User{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for users") + } + + return o, nil +} + +// All returns all User records from the query. +func (q userQuery) All(ctx context.Context, exec boil.ContextExecutor) (UserSlice, error) { + var o []*User + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to User slice") + } + + return o, nil +} + +// Count returns the count of all User records in the query. +func (q userQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count users rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q userQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if users exists") + } + + return count > 0, nil +} + +// AppUserProfile pointed to by the foreign key. +func (o *User) AppUserProfile(mods ...qm.QueryMod) appUserProfileQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"user_id\" = ?", o.ID), + } + + queryMods = append(queryMods, mods...) + + return AppUserProfiles(queryMods...) +} + +// AccessTokens retrieves all the access_token's AccessTokens with an executor. +func (o *User) AccessTokens(mods ...qm.QueryMod) accessTokenQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"access_tokens\".\"user_id\"=?", o.ID), + ) + + return AccessTokens(queryMods...) +} + +// ConfirmationTokens retrieves all the confirmation_token's ConfirmationTokens with an executor. +func (o *User) ConfirmationTokens(mods ...qm.QueryMod) confirmationTokenQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"confirmation_tokens\".\"user_id\"=?", o.ID), + ) + + return ConfirmationTokens(queryMods...) +} + +// PasswordResetTokens retrieves all the password_reset_token's PasswordResetTokens with an executor. +func (o *User) PasswordResetTokens(mods ...qm.QueryMod) passwordResetTokenQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"password_reset_tokens\".\"user_id\"=?", o.ID), + ) + + return PasswordResetTokens(queryMods...) +} + +// PushTokens retrieves all the push_token's PushTokens with an executor. +func (o *User) PushTokens(mods ...qm.QueryMod) pushTokenQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"push_tokens\".\"user_id\"=?", o.ID), + ) + + return PushTokens(queryMods...) +} + +// RefreshTokens retrieves all the refresh_token's RefreshTokens with an executor. +func (o *User) RefreshTokens(mods ...qm.QueryMod) refreshTokenQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"refresh_tokens\".\"user_id\"=?", o.ID), + ) + + return RefreshTokens(queryMods...) +} + +// LoadAppUserProfile allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-1 relationship. +func (userL) LoadAppUserProfile(ctx context.Context, e boil.ContextExecutor, singular bool, maybeUser interface{}, mods queries.Applicator) error { + var slice []*User + var object *User + + if singular { + var ok bool + object, ok = maybeUser.(*User) + if !ok { + object = new(User) + ok = queries.SetFromEmbeddedStruct(&object, &maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeUser)) + } + } + } else { + s, ok := maybeUser.(*[]*User) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeUser)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &userR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &userR{} + } + + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`app_user_profiles`), + qm.WhereIn(`app_user_profiles.user_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load AppUserProfile") + } + + var resultSlice []*AppUserProfile + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice AppUserProfile") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for app_user_profiles") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for app_user_profiles") + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.AppUserProfile = foreign + if foreign.R == nil { + foreign.R = &appUserProfileR{} + } + foreign.R.User = object + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.ID == foreign.UserID { + local.R.AppUserProfile = foreign + if foreign.R == nil { + foreign.R = &appUserProfileR{} + } + foreign.R.User = local + break + } + } + } + + return nil +} + +// LoadAccessTokens allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (userL) LoadAccessTokens(ctx context.Context, e boil.ContextExecutor, singular bool, maybeUser interface{}, mods queries.Applicator) error { + var slice []*User + var object *User + + if singular { + var ok bool + object, ok = maybeUser.(*User) + if !ok { + object = new(User) + ok = queries.SetFromEmbeddedStruct(&object, &maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeUser)) + } + } + } else { + s, ok := maybeUser.(*[]*User) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeUser)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &userR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &userR{} + } + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`access_tokens`), + qm.WhereIn(`access_tokens.user_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load access_tokens") + } + + var resultSlice []*AccessToken + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice access_tokens") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on access_tokens") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for access_tokens") + } + + if singular { + object.R.AccessTokens = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &accessTokenR{} + } + foreign.R.User = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.UserID { + local.R.AccessTokens = append(local.R.AccessTokens, foreign) + if foreign.R == nil { + foreign.R = &accessTokenR{} + } + foreign.R.User = local + break + } + } + } + + return nil +} + +// LoadConfirmationTokens allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (userL) LoadConfirmationTokens(ctx context.Context, e boil.ContextExecutor, singular bool, maybeUser interface{}, mods queries.Applicator) error { + var slice []*User + var object *User + + if singular { + var ok bool + object, ok = maybeUser.(*User) + if !ok { + object = new(User) + ok = queries.SetFromEmbeddedStruct(&object, &maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeUser)) + } + } + } else { + s, ok := maybeUser.(*[]*User) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeUser)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &userR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &userR{} + } + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`confirmation_tokens`), + qm.WhereIn(`confirmation_tokens.user_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load confirmation_tokens") + } + + var resultSlice []*ConfirmationToken + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice confirmation_tokens") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on confirmation_tokens") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for confirmation_tokens") + } + + if singular { + object.R.ConfirmationTokens = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &confirmationTokenR{} + } + foreign.R.User = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.UserID { + local.R.ConfirmationTokens = append(local.R.ConfirmationTokens, foreign) + if foreign.R == nil { + foreign.R = &confirmationTokenR{} + } + foreign.R.User = local + break + } + } + } + + return nil +} + +// LoadPasswordResetTokens allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (userL) LoadPasswordResetTokens(ctx context.Context, e boil.ContextExecutor, singular bool, maybeUser interface{}, mods queries.Applicator) error { + var slice []*User + var object *User + + if singular { + var ok bool + object, ok = maybeUser.(*User) + if !ok { + object = new(User) + ok = queries.SetFromEmbeddedStruct(&object, &maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeUser)) + } + } + } else { + s, ok := maybeUser.(*[]*User) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeUser)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &userR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &userR{} + } + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`password_reset_tokens`), + qm.WhereIn(`password_reset_tokens.user_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load password_reset_tokens") + } + + var resultSlice []*PasswordResetToken + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice password_reset_tokens") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on password_reset_tokens") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for password_reset_tokens") + } + + if singular { + object.R.PasswordResetTokens = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &passwordResetTokenR{} + } + foreign.R.User = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.UserID { + local.R.PasswordResetTokens = append(local.R.PasswordResetTokens, foreign) + if foreign.R == nil { + foreign.R = &passwordResetTokenR{} + } + foreign.R.User = local + break + } + } + } + + return nil +} + +// LoadPushTokens allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (userL) LoadPushTokens(ctx context.Context, e boil.ContextExecutor, singular bool, maybeUser interface{}, mods queries.Applicator) error { + var slice []*User + var object *User + + if singular { + var ok bool + object, ok = maybeUser.(*User) + if !ok { + object = new(User) + ok = queries.SetFromEmbeddedStruct(&object, &maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeUser)) + } + } + } else { + s, ok := maybeUser.(*[]*User) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeUser)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &userR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &userR{} + } + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`push_tokens`), + qm.WhereIn(`push_tokens.user_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load push_tokens") + } + + var resultSlice []*PushToken + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice push_tokens") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on push_tokens") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for push_tokens") + } + + if singular { + object.R.PushTokens = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &pushTokenR{} + } + foreign.R.User = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.UserID { + local.R.PushTokens = append(local.R.PushTokens, foreign) + if foreign.R == nil { + foreign.R = &pushTokenR{} + } + foreign.R.User = local + break + } + } + } + + return nil +} + +// LoadRefreshTokens allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (userL) LoadRefreshTokens(ctx context.Context, e boil.ContextExecutor, singular bool, maybeUser interface{}, mods queries.Applicator) error { + var slice []*User + var object *User + + if singular { + var ok bool + object, ok = maybeUser.(*User) + if !ok { + object = new(User) + ok = queries.SetFromEmbeddedStruct(&object, &maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeUser)) + } + } + } else { + s, ok := maybeUser.(*[]*User) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeUser) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeUser)) + } + } + } + + args := make(map[interface{}]struct{}) + if singular { + if object.R == nil { + object.R = &userR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &userR{} + } + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]interface{}, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`refresh_tokens`), + qm.WhereIn(`refresh_tokens.user_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load refresh_tokens") + } + + var resultSlice []*RefreshToken + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice refresh_tokens") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on refresh_tokens") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for refresh_tokens") + } + + if singular { + object.R.RefreshTokens = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &refreshTokenR{} + } + foreign.R.User = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.UserID { + local.R.RefreshTokens = append(local.R.RefreshTokens, foreign) + if foreign.R == nil { + foreign.R = &refreshTokenR{} + } + foreign.R.User = local + break + } + } + } + + return nil +} + +// SetAppUserProfile of the user to the related item. +// Sets o.R.AppUserProfile to related. +// Adds o to related.R.User. +func (o *User) SetAppUserProfile(ctx context.Context, exec boil.ContextExecutor, insert bool, related *AppUserProfile) error { + var err error + + if insert { + related.UserID = o.ID + + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"app_user_profiles\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, appUserProfilePrimaryKeyColumns), + ) + values := []interface{}{o.ID, related.UserID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + related.UserID = o.ID + } + + if o.R == nil { + o.R = &userR{ + AppUserProfile: related, + } + } else { + o.R.AppUserProfile = related + } + + if related.R == nil { + related.R = &appUserProfileR{ + User: o, + } + } else { + related.R.User = o + } + return nil +} + +// AddAccessTokens adds the given related objects to the existing relationships +// of the user, optionally inserting them as new records. +// Appends related to o.R.AccessTokens. +// Sets related.R.User appropriately. +func (o *User) AddAccessTokens(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*AccessToken) error { + var err error + for _, rel := range related { + if insert { + rel.UserID = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"access_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, accessTokenPrimaryKeyColumns), + ) + values := []interface{}{o.ID, rel.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.UserID = o.ID + } + } + + if o.R == nil { + o.R = &userR{ + AccessTokens: related, + } + } else { + o.R.AccessTokens = append(o.R.AccessTokens, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &accessTokenR{ + User: o, + } + } else { + rel.R.User = o + } + } + return nil +} + +// AddConfirmationTokens adds the given related objects to the existing relationships +// of the user, optionally inserting them as new records. +// Appends related to o.R.ConfirmationTokens. +// Sets related.R.User appropriately. +func (o *User) AddConfirmationTokens(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*ConfirmationToken) error { + var err error + for _, rel := range related { + if insert { + rel.UserID = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"confirmation_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, confirmationTokenPrimaryKeyColumns), + ) + values := []interface{}{o.ID, rel.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.UserID = o.ID + } + } + + if o.R == nil { + o.R = &userR{ + ConfirmationTokens: related, + } + } else { + o.R.ConfirmationTokens = append(o.R.ConfirmationTokens, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &confirmationTokenR{ + User: o, + } + } else { + rel.R.User = o + } + } + return nil +} + +// AddPasswordResetTokens adds the given related objects to the existing relationships +// of the user, optionally inserting them as new records. +// Appends related to o.R.PasswordResetTokens. +// Sets related.R.User appropriately. +func (o *User) AddPasswordResetTokens(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*PasswordResetToken) error { + var err error + for _, rel := range related { + if insert { + rel.UserID = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"password_reset_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, passwordResetTokenPrimaryKeyColumns), + ) + values := []interface{}{o.ID, rel.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.UserID = o.ID + } + } + + if o.R == nil { + o.R = &userR{ + PasswordResetTokens: related, + } + } else { + o.R.PasswordResetTokens = append(o.R.PasswordResetTokens, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &passwordResetTokenR{ + User: o, + } + } else { + rel.R.User = o + } + } + return nil +} + +// AddPushTokens adds the given related objects to the existing relationships +// of the user, optionally inserting them as new records. +// Appends related to o.R.PushTokens. +// Sets related.R.User appropriately. +func (o *User) AddPushTokens(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*PushToken) error { + var err error + for _, rel := range related { + if insert { + rel.UserID = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"push_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, pushTokenPrimaryKeyColumns), + ) + values := []interface{}{o.ID, rel.ID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.UserID = o.ID + } + } + + if o.R == nil { + o.R = &userR{ + PushTokens: related, + } + } else { + o.R.PushTokens = append(o.R.PushTokens, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &pushTokenR{ + User: o, + } + } else { + rel.R.User = o + } + } + return nil +} + +// AddRefreshTokens adds the given related objects to the existing relationships +// of the user, optionally inserting them as new records. +// Appends related to o.R.RefreshTokens. +// Sets related.R.User appropriately. +func (o *User) AddRefreshTokens(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*RefreshToken) error { + var err error + for _, rel := range related { + if insert { + rel.UserID = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"refresh_tokens\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"user_id"}), + strmangle.WhereClause("\"", "\"", 2, refreshTokenPrimaryKeyColumns), + ) + values := []interface{}{o.ID, rel.Token} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.UserID = o.ID + } + } + + if o.R == nil { + o.R = &userR{ + RefreshTokens: related, + } + } else { + o.R.RefreshTokens = append(o.R.RefreshTokens, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &refreshTokenR{ + User: o, + } + } else { + rel.R.User = o + } + } + return nil +} + +// Users retrieves all the records using an executor. +func Users(mods ...qm.QueryMod) userQuery { + mods = append(mods, qm.From("\"users\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"users\".*"}) + } + + return userQuery{q} +} + +// FindUser retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindUser(ctx context.Context, exec boil.ContextExecutor, iD string, selectCols ...string) (*User, error) { + userObj := &User{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"users\" where \"id\"=$1", sel, + ) + + q := queries.Raw(query, iD) + + err := q.Bind(ctx, exec, userObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from users") + } + + return userObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *User) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no users provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(userColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + userInsertCacheMut.RLock() + cache, cached := userInsertCache[key] + userInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + userAllColumns, + userColumnsWithDefault, + userColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(userType, userMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(userType, userMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"users\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"users\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into users") + } + + if !cached { + userInsertCacheMut.Lock() + userInsertCache[key] = cache + userInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the User. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *User) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + userUpdateCacheMut.RLock() + cache, cached := userUpdateCache[key] + userUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + userAllColumns, + userPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update users, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"users\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, userPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(userType, userMapping, append(wl, userPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update users row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for users") + } + + if !cached { + userUpdateCacheMut.Lock() + userUpdateCache[key] = cache + userUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q userQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for users") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for users") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o UserSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), userPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"users\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, userPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in user slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all user") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *User) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no users provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(userColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + userUpsertCacheMut.RLock() + cache, cached := userUpsertCache[key] + userUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + userAllColumns, + userColumnsWithDefault, + userColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + userAllColumns, + userPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert users, could not build update column list") + } + + ret := strmangle.SetComplement(userAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(userPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert users, could not build conflict column list") + } + + conflict = make([]string, len(userPrimaryKeyColumns)) + copy(conflict, userPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"users\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(userType, userMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(userType, userMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert users") + } + + if !cached { + userUpsertCacheMut.Lock() + userUpsertCache[key] = cache + userUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single User record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *User) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no User provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), userPrimaryKeyMapping) + sql := "DELETE FROM \"users\" WHERE \"id\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from users") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for users") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q userQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no userQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from users") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for users") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o UserSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), userPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"users\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, userPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from user slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for users") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *User) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindUser(ctx, exec, o.ID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *UserSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := UserSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), userPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"users\".* FROM \"users\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, userPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in UserSlice") + } + + *o = slice + + return nil +} + +// UserExists checks if the User row exists. +func UserExists(ctx context.Context, exec boil.ContextExecutor, iD string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"users\" where \"id\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, iD) + } + row := exec.QueryRowContext(ctx, sql, iD) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if users exists") + } + + return exists, nil +} + +// Exists checks if the User row exists. +func (o *User) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return UserExists(ctx, exec, o.ID) +} diff --git a/internal/models/users_test.go b/internal/models/users_test.go new file mode 100644 index 0000000..4acec06 --- /dev/null +++ b/internal/models/users_test.go @@ -0,0 +1,1470 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "bytes" + "context" + "reflect" + "testing" + + "github.com/aarondl/randomize" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/strmangle" +) + +var ( + // Relationships sometimes use the reflection helper queries.Equal/queries.Assign + // so force a package dependency in case they don't. + _ = queries.Equal +) + +func testUsers(t *testing.T) { + t.Parallel() + + query := Users() + + if query.Query == nil { + t.Error("expected a query, got nothing") + } +} + +func testUsersDelete(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := o.Delete(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testUsersQueryDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if rowsAff, err := Users().DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testUsersSliceDeleteAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := UserSlice{o} + + if rowsAff, err := slice.DeleteAll(ctx, tx); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only have deleted one row, but affected:", rowsAff) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 0 { + t.Error("want zero records, got:", count) + } +} + +func testUsersExists(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + e, err := UserExists(ctx, tx, o.ID) + if err != nil { + t.Errorf("Unable to check if User exists: %s", err) + } + if !e { + t.Errorf("Expected UserExists to return true, but got false.") + } +} + +func testUsersFind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + userFound, err := FindUser(ctx, tx, o.ID) + if err != nil { + t.Error(err) + } + + if userFound == nil { + t.Error("want a record, got nil") + } +} + +func testUsersBind(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = Users().Bind(ctx, tx, o); err != nil { + t.Error(err) + } +} + +func testUsersOne(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if x, err := Users().One(ctx, tx); err != nil { + t.Error(err) + } else if x == nil { + t.Error("expected to get a non nil record") + } +} + +func testUsersAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + userOne := &User{} + userTwo := &User{} + if err = randomize.Struct(seed, userOne, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + if err = randomize.Struct(seed, userTwo, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = userOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = userTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := Users().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 2 { + t.Error("want 2 records, got:", len(slice)) + } +} + +func testUsersCount(t *testing.T) { + t.Parallel() + + var err error + seed := randomize.NewSeed() + userOne := &User{} + userTwo := &User{} + if err = randomize.Struct(seed, userOne, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + if err = randomize.Struct(seed, userTwo, userDBTypes, false, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = userOne.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + if err = userTwo.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 2 { + t.Error("want 2 records, got:", count) + } +} + +func testUsersInsert(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testUsersInsertWhitelist(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Whitelist(strmangle.SetMerge(userPrimaryKeyColumns, userColumnsWithoutDefault)...)); err != nil { + t.Error(err) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } +} + +func testUserOneToOneAppUserProfileUsingAppUserProfile(t *testing.T) { + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var foreign AppUserProfile + var local User + + seed := randomize.NewSeed() + if err := randomize.Struct(seed, &foreign, appUserProfileDBTypes, true, appUserProfileColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize AppUserProfile struct: %s", err) + } + if err := randomize.Struct(seed, &local, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := local.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + foreign.UserID = local.ID + if err := foreign.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := local.AppUserProfile().One(ctx, tx) + if err != nil { + t.Fatal(err) + } + + if check.UserID != foreign.UserID { + t.Errorf("want: %v, got %v", foreign.UserID, check.UserID) + } + + slice := UserSlice{&local} + if err = local.L.LoadAppUserProfile(ctx, tx, false, (*[]*User)(&slice), nil); err != nil { + t.Fatal(err) + } + if local.R.AppUserProfile == nil { + t.Error("struct should have been eager loaded") + } + + local.R.AppUserProfile = nil + if err = local.L.LoadAppUserProfile(ctx, tx, true, &local, nil); err != nil { + t.Fatal(err) + } + if local.R.AppUserProfile == nil { + t.Error("struct should have been eager loaded") + } + +} + +func testUserOneToOneSetOpAppUserProfileUsingAppUserProfile(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c AppUserProfile + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &b, appUserProfileDBTypes, false, strmangle.SetComplement(appUserProfilePrimaryKeyColumns, appUserProfileColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, appUserProfileDBTypes, false, strmangle.SetComplement(appUserProfilePrimaryKeyColumns, appUserProfileColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + for i, x := range []*AppUserProfile{&b, &c} { + err = a.SetAppUserProfile(ctx, tx, i != 0, x) + if err != nil { + t.Fatal(err) + } + + if a.R.AppUserProfile != x { + t.Error("relationship struct not set to correct value") + } + if x.R.User != &a { + t.Error("failed to append to foreign relationship struct") + } + + if a.ID != x.UserID { + t.Error("foreign key was wrong value", a.ID) + } + + if exists, err := AppUserProfileExists(ctx, tx, x.UserID); err != nil { + t.Fatal(err) + } else if !exists { + t.Error("want 'x' to exist") + } + + if a.ID != x.UserID { + t.Error("foreign key was wrong value", a.ID, x.UserID) + } + + if _, err = x.Delete(ctx, tx); err != nil { + t.Fatal("failed to delete x", err) + } + } +} + +func testUserToManyAccessTokens(t *testing.T) { + var err error + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c AccessToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + if err = randomize.Struct(seed, &b, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, accessTokenDBTypes, false, accessTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + + b.UserID = a.ID + c.UserID = a.ID + + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := a.AccessTokens().All(ctx, tx) + if err != nil { + t.Fatal(err) + } + + bFound, cFound := false, false + for _, v := range check { + if v.UserID == b.UserID { + bFound = true + } + if v.UserID == c.UserID { + cFound = true + } + } + + if !bFound { + t.Error("expected to find b") + } + if !cFound { + t.Error("expected to find c") + } + + slice := UserSlice{&a} + if err = a.L.LoadAccessTokens(ctx, tx, false, (*[]*User)(&slice), nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.AccessTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + a.R.AccessTokens = nil + if err = a.L.LoadAccessTokens(ctx, tx, true, &a, nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.AccessTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + if t.Failed() { + t.Logf("%#v", check) + } +} + +func testUserToManyConfirmationTokens(t *testing.T) { + var err error + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c ConfirmationToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + if err = randomize.Struct(seed, &b, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, confirmationTokenDBTypes, false, confirmationTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + + b.UserID = a.ID + c.UserID = a.ID + + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := a.ConfirmationTokens().All(ctx, tx) + if err != nil { + t.Fatal(err) + } + + bFound, cFound := false, false + for _, v := range check { + if v.UserID == b.UserID { + bFound = true + } + if v.UserID == c.UserID { + cFound = true + } + } + + if !bFound { + t.Error("expected to find b") + } + if !cFound { + t.Error("expected to find c") + } + + slice := UserSlice{&a} + if err = a.L.LoadConfirmationTokens(ctx, tx, false, (*[]*User)(&slice), nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.ConfirmationTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + a.R.ConfirmationTokens = nil + if err = a.L.LoadConfirmationTokens(ctx, tx, true, &a, nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.ConfirmationTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + if t.Failed() { + t.Logf("%#v", check) + } +} + +func testUserToManyPasswordResetTokens(t *testing.T) { + var err error + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c PasswordResetToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + if err = randomize.Struct(seed, &b, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, passwordResetTokenDBTypes, false, passwordResetTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + + b.UserID = a.ID + c.UserID = a.ID + + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := a.PasswordResetTokens().All(ctx, tx) + if err != nil { + t.Fatal(err) + } + + bFound, cFound := false, false + for _, v := range check { + if v.UserID == b.UserID { + bFound = true + } + if v.UserID == c.UserID { + cFound = true + } + } + + if !bFound { + t.Error("expected to find b") + } + if !cFound { + t.Error("expected to find c") + } + + slice := UserSlice{&a} + if err = a.L.LoadPasswordResetTokens(ctx, tx, false, (*[]*User)(&slice), nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.PasswordResetTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + a.R.PasswordResetTokens = nil + if err = a.L.LoadPasswordResetTokens(ctx, tx, true, &a, nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.PasswordResetTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + if t.Failed() { + t.Logf("%#v", check) + } +} + +func testUserToManyPushTokens(t *testing.T) { + var err error + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c PushToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + if err = randomize.Struct(seed, &b, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, pushTokenDBTypes, false, pushTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + + b.UserID = a.ID + c.UserID = a.ID + + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := a.PushTokens().All(ctx, tx) + if err != nil { + t.Fatal(err) + } + + bFound, cFound := false, false + for _, v := range check { + if v.UserID == b.UserID { + bFound = true + } + if v.UserID == c.UserID { + cFound = true + } + } + + if !bFound { + t.Error("expected to find b") + } + if !cFound { + t.Error("expected to find c") + } + + slice := UserSlice{&a} + if err = a.L.LoadPushTokens(ctx, tx, false, (*[]*User)(&slice), nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.PushTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + a.R.PushTokens = nil + if err = a.L.LoadPushTokens(ctx, tx, true, &a, nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.PushTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + if t.Failed() { + t.Logf("%#v", check) + } +} + +func testUserToManyRefreshTokens(t *testing.T) { + var err error + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c RefreshToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + if err = randomize.Struct(seed, &b, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + if err = randomize.Struct(seed, &c, refreshTokenDBTypes, false, refreshTokenColumnsWithDefault...); err != nil { + t.Fatal(err) + } + + b.UserID = a.ID + c.UserID = a.ID + + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + check, err := a.RefreshTokens().All(ctx, tx) + if err != nil { + t.Fatal(err) + } + + bFound, cFound := false, false + for _, v := range check { + if v.UserID == b.UserID { + bFound = true + } + if v.UserID == c.UserID { + cFound = true + } + } + + if !bFound { + t.Error("expected to find b") + } + if !cFound { + t.Error("expected to find c") + } + + slice := UserSlice{&a} + if err = a.L.LoadRefreshTokens(ctx, tx, false, (*[]*User)(&slice), nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.RefreshTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + a.R.RefreshTokens = nil + if err = a.L.LoadRefreshTokens(ctx, tx, true, &a, nil); err != nil { + t.Fatal(err) + } + if got := len(a.R.RefreshTokens); got != 2 { + t.Error("number of eager loaded records wrong, got:", got) + } + + if t.Failed() { + t.Logf("%#v", check) + } +} + +func testUserToManyAddOpAccessTokens(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c, d, e AccessToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + foreigners := []*AccessToken{&b, &c, &d, &e} + for _, x := range foreigners { + if err = randomize.Struct(seed, x, accessTokenDBTypes, false, strmangle.SetComplement(accessTokenPrimaryKeyColumns, accessTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + foreignersSplitByInsertion := [][]*AccessToken{ + {&b, &c}, + {&d, &e}, + } + + for i, x := range foreignersSplitByInsertion { + err = a.AddAccessTokens(ctx, tx, i != 0, x...) + if err != nil { + t.Fatal(err) + } + + first := x[0] + second := x[1] + + if a.ID != first.UserID { + t.Error("foreign key was wrong value", a.ID, first.UserID) + } + if a.ID != second.UserID { + t.Error("foreign key was wrong value", a.ID, second.UserID) + } + + if first.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + if second.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + + if a.R.AccessTokens[i*2] != first { + t.Error("relationship struct slice not set to correct value") + } + if a.R.AccessTokens[i*2+1] != second { + t.Error("relationship struct slice not set to correct value") + } + + count, err := a.AccessTokens().Count(ctx, tx) + if err != nil { + t.Fatal(err) + } + if want := int64((i + 1) * 2); count != want { + t.Error("want", want, "got", count) + } + } +} +func testUserToManyAddOpConfirmationTokens(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c, d, e ConfirmationToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + foreigners := []*ConfirmationToken{&b, &c, &d, &e} + for _, x := range foreigners { + if err = randomize.Struct(seed, x, confirmationTokenDBTypes, false, strmangle.SetComplement(confirmationTokenPrimaryKeyColumns, confirmationTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + foreignersSplitByInsertion := [][]*ConfirmationToken{ + {&b, &c}, + {&d, &e}, + } + + for i, x := range foreignersSplitByInsertion { + err = a.AddConfirmationTokens(ctx, tx, i != 0, x...) + if err != nil { + t.Fatal(err) + } + + first := x[0] + second := x[1] + + if a.ID != first.UserID { + t.Error("foreign key was wrong value", a.ID, first.UserID) + } + if a.ID != second.UserID { + t.Error("foreign key was wrong value", a.ID, second.UserID) + } + + if first.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + if second.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + + if a.R.ConfirmationTokens[i*2] != first { + t.Error("relationship struct slice not set to correct value") + } + if a.R.ConfirmationTokens[i*2+1] != second { + t.Error("relationship struct slice not set to correct value") + } + + count, err := a.ConfirmationTokens().Count(ctx, tx) + if err != nil { + t.Fatal(err) + } + if want := int64((i + 1) * 2); count != want { + t.Error("want", want, "got", count) + } + } +} +func testUserToManyAddOpPasswordResetTokens(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c, d, e PasswordResetToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + foreigners := []*PasswordResetToken{&b, &c, &d, &e} + for _, x := range foreigners { + if err = randomize.Struct(seed, x, passwordResetTokenDBTypes, false, strmangle.SetComplement(passwordResetTokenPrimaryKeyColumns, passwordResetTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + foreignersSplitByInsertion := [][]*PasswordResetToken{ + {&b, &c}, + {&d, &e}, + } + + for i, x := range foreignersSplitByInsertion { + err = a.AddPasswordResetTokens(ctx, tx, i != 0, x...) + if err != nil { + t.Fatal(err) + } + + first := x[0] + second := x[1] + + if a.ID != first.UserID { + t.Error("foreign key was wrong value", a.ID, first.UserID) + } + if a.ID != second.UserID { + t.Error("foreign key was wrong value", a.ID, second.UserID) + } + + if first.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + if second.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + + if a.R.PasswordResetTokens[i*2] != first { + t.Error("relationship struct slice not set to correct value") + } + if a.R.PasswordResetTokens[i*2+1] != second { + t.Error("relationship struct slice not set to correct value") + } + + count, err := a.PasswordResetTokens().Count(ctx, tx) + if err != nil { + t.Fatal(err) + } + if want := int64((i + 1) * 2); count != want { + t.Error("want", want, "got", count) + } + } +} +func testUserToManyAddOpPushTokens(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c, d, e PushToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + foreigners := []*PushToken{&b, &c, &d, &e} + for _, x := range foreigners { + if err = randomize.Struct(seed, x, pushTokenDBTypes, false, strmangle.SetComplement(pushTokenPrimaryKeyColumns, pushTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + foreignersSplitByInsertion := [][]*PushToken{ + {&b, &c}, + {&d, &e}, + } + + for i, x := range foreignersSplitByInsertion { + err = a.AddPushTokens(ctx, tx, i != 0, x...) + if err != nil { + t.Fatal(err) + } + + first := x[0] + second := x[1] + + if a.ID != first.UserID { + t.Error("foreign key was wrong value", a.ID, first.UserID) + } + if a.ID != second.UserID { + t.Error("foreign key was wrong value", a.ID, second.UserID) + } + + if first.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + if second.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + + if a.R.PushTokens[i*2] != first { + t.Error("relationship struct slice not set to correct value") + } + if a.R.PushTokens[i*2+1] != second { + t.Error("relationship struct slice not set to correct value") + } + + count, err := a.PushTokens().Count(ctx, tx) + if err != nil { + t.Fatal(err) + } + if want := int64((i + 1) * 2); count != want { + t.Error("want", want, "got", count) + } + } +} +func testUserToManyAddOpRefreshTokens(t *testing.T) { + var err error + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + + var a User + var b, c, d, e RefreshToken + + seed := randomize.NewSeed() + if err = randomize.Struct(seed, &a, userDBTypes, false, strmangle.SetComplement(userPrimaryKeyColumns, userColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + foreigners := []*RefreshToken{&b, &c, &d, &e} + for _, x := range foreigners { + if err = randomize.Struct(seed, x, refreshTokenDBTypes, false, strmangle.SetComplement(refreshTokenPrimaryKeyColumns, refreshTokenColumnsWithoutDefault)...); err != nil { + t.Fatal(err) + } + } + + if err := a.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = b.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + if err = c.Insert(ctx, tx, boil.Infer()); err != nil { + t.Fatal(err) + } + + foreignersSplitByInsertion := [][]*RefreshToken{ + {&b, &c}, + {&d, &e}, + } + + for i, x := range foreignersSplitByInsertion { + err = a.AddRefreshTokens(ctx, tx, i != 0, x...) + if err != nil { + t.Fatal(err) + } + + first := x[0] + second := x[1] + + if a.ID != first.UserID { + t.Error("foreign key was wrong value", a.ID, first.UserID) + } + if a.ID != second.UserID { + t.Error("foreign key was wrong value", a.ID, second.UserID) + } + + if first.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + if second.R.User != &a { + t.Error("relationship was not added properly to the foreign slice") + } + + if a.R.RefreshTokens[i*2] != first { + t.Error("relationship struct slice not set to correct value") + } + if a.R.RefreshTokens[i*2+1] != second { + t.Error("relationship struct slice not set to correct value") + } + + count, err := a.RefreshTokens().Count(ctx, tx) + if err != nil { + t.Fatal(err) + } + if want := int64((i + 1) * 2); count != want { + t.Error("want", want, "got", count) + } + } +} + +func testUsersReload(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + if err = o.Reload(ctx, tx); err != nil { + t.Error(err) + } +} + +func testUsersReloadAll(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice := UserSlice{o} + + if err = slice.ReloadAll(ctx, tx); err != nil { + t.Error(err) + } +} + +func testUsersSelect(t *testing.T) { + t.Parallel() + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + slice, err := Users().All(ctx, tx) + if err != nil { + t.Error(err) + } + + if len(slice) != 1 { + t.Error("want one record, got:", len(slice)) + } +} + +var ( + userDBTypes = map[string]string{`ID`: `uuid`, `Username`: `character varying`, `Password`: `text`, `IsActive`: `boolean`, `Scopes`: `ARRAYtext`, `LastAuthenticatedAt`: `timestamp with time zone`, `CreatedAt`: `timestamp with time zone`, `UpdatedAt`: `timestamp with time zone`, `RequiresConfirmation`: `boolean`} + _ = bytes.MinRead +) + +func testUsersUpdate(t *testing.T) { + t.Parallel() + + if 0 == len(userPrimaryKeyColumns) { + t.Skip("Skipping table with no primary key columns") + } + if len(userAllColumns) == len(userPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, userDBTypes, true, userPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if rowsAff, err := o.Update(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("should only affect one row but affected", rowsAff) + } +} + +func testUsersSliceUpdateAll(t *testing.T) { + t.Parallel() + + if len(userAllColumns) == len(userPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + o := &User{} + if err = randomize.Struct(seed, o, userDBTypes, true, userColumnsWithDefault...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Insert(ctx, tx, boil.Infer()); err != nil { + t.Error(err) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + + if count != 1 { + t.Error("want one record, got:", count) + } + + if err = randomize.Struct(seed, o, userDBTypes, true, userPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + // Remove Primary keys and unique columns from what we plan to update + var fields []string + if strmangle.StringSliceMatch(userAllColumns, userPrimaryKeyColumns) { + fields = userAllColumns + } else { + fields = strmangle.SetComplement( + userAllColumns, + userPrimaryKeyColumns, + ) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + typ := reflect.TypeOf(o).Elem() + n := typ.NumField() + + updateMap := M{} + for _, col := range fields { + for i := 0; i < n; i++ { + f := typ.Field(i) + if f.Tag.Get("boil") == col { + updateMap[col] = value.Field(i).Interface() + } + } + } + + slice := UserSlice{o} + if rowsAff, err := slice.UpdateAll(ctx, tx, updateMap); err != nil { + t.Error(err) + } else if rowsAff != 1 { + t.Error("wanted one record updated but got", rowsAff) + } +} + +func testUsersUpsert(t *testing.T) { + t.Parallel() + + if len(userAllColumns) == len(userPrimaryKeyColumns) { + t.Skip("Skipping table with only primary key columns") + } + + seed := randomize.NewSeed() + var err error + // Attempt the INSERT side of an UPSERT + o := User{} + if err = randomize.Struct(seed, &o, userDBTypes, true); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + ctx := context.Background() + tx := MustTx(boil.BeginTx(ctx, nil)) + defer func() { _ = tx.Rollback() }() + if err = o.Upsert(ctx, tx, false, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert User: %s", err) + } + + count, err := Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } + + // Attempt the UPDATE side of an UPSERT + if err = randomize.Struct(seed, &o, userDBTypes, false, userPrimaryKeyColumns...); err != nil { + t.Errorf("Unable to randomize User struct: %s", err) + } + + if err = o.Upsert(ctx, tx, true, nil, boil.Infer(), boil.Infer()); err != nil { + t.Errorf("Unable to upsert User: %s", err) + } + + count, err = Users().Count(ctx, tx) + if err != nil { + t.Error(err) + } + if count != 1 { + t.Error("want one record, got:", count) + } +} diff --git a/internal/persistence/postgres.go b/internal/persistence/postgres.go new file mode 100644 index 0000000..ac2eeb1 --- /dev/null +++ b/internal/persistence/postgres.go @@ -0,0 +1,40 @@ +package persistence + +import ( + "context" + "database/sql" + "fmt" + "time" + + "allaboutapps.dev/aw/go-starter/internal/config" +) + +const ( + dbPingTimeout = 10 * time.Second +) + +func NewDB(cfg config.Database) (*sql.DB, error) { + db, err := sql.Open("postgres", cfg.ConnectionString()) + if err != nil { + return nil, fmt.Errorf("failed to open db connection: %w", err) + } + + if cfg.MaxOpenConns > 0 { + db.SetMaxOpenConns(cfg.MaxOpenConns) + } + if cfg.MaxIdleConns > 0 { + db.SetMaxIdleConns(cfg.MaxIdleConns) + } + if cfg.ConnMaxLifetime > 0 { + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + } + + ctx, cancel := context.WithTimeout(context.Background(), dbPingTimeout) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to ping DB: %w", err) + } + + return db, nil +} diff --git a/internal/persistence/postgres_test.go b/internal/persistence/postgres_test.go new file mode 100644 index 0000000..ba8a9bf --- /dev/null +++ b/internal/persistence/postgres_test.go @@ -0,0 +1,63 @@ +package persistence_test + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test" + migrate "github.com/rubenv/sql-migrate" + "github.com/stretchr/testify/require" +) + +func TestMigrations(t *testing.T) { + test.WithTestDatabaseEmpty(t, func(db *sql.DB) { + migrate.SetTable(config.DatabaseMigrationTable) + + // use the migrations from the migrations folder + migrationSource := &migrate.FileMigrationSource{Dir: config.DatabaseMigrationFolder} + + // run all up migrations + missingUpMigrations, _, err := migrate.PlanMigration(db, "postgres", migrationSource, migrate.Up, 0) + require.NoError(t, err) + require.NotEmpty(t, missingUpMigrations) + + for _, migration := range missingUpMigrations { + n, err := migrate.ExecMax(db, "postgres", migrationSource, migrate.Up, 1) + require.NoError(t, err, "failed to apply up migration %s", migration.Id) + require.Equal(t, 1, n, "expected 1 migration to be applied for %s, got %d", migration.Id, n) + } + + // expect all migrations to be applied + missingUpMigrationsAfterApply, _, err := migrate.PlanMigration(db, "postgres", migrationSource, migrate.Up, 0) + require.NoError(t, err) + require.Empty(t, missingUpMigrationsAfterApply) + + // run all down migrations + downMigrations, _, err := migrate.PlanMigration(db, "postgres", migrationSource, migrate.Down, 0) + require.NoError(t, err) + require.NotEmpty(t, downMigrations) + + for _, migration := range downMigrations { + n, err := migrate.ExecMax(db, "postgres", migrationSource, migrate.Down, 1) + require.NoError(t, err, "failed to apply down migration %s", migration.Id) + require.Equal(t, 1, n, "expected 1 migration to be applied for %s, got %d", migration.Id, n) + } + + // expect all down migrations to be applied + missingDownMigrationsAfterDown, _, err := migrate.PlanMigration(db, "postgres", migrationSource, migrate.Down, 0) + require.NoError(t, err) + require.Empty(t, missingDownMigrationsAfterDown) + + // run all up migrations again to test if the down migrations cleanup the database correctly + upMigrations, _, err := migrate.PlanMigration(db, "postgres", migrationSource, migrate.Up, 0) + require.NoError(t, err) + require.NotEmpty(t, upMigrations) + + for _, migration := range upMigrations { + n, err := migrate.ExecMax(db, "postgres", migrationSource, migrate.Up, 1) + require.NoError(t, err, "failed to apply up migration %s after down migrations", migration.Id) + require.Equal(t, 1, n, "expected 1 migration to be applied for %s after down migrations, got %d", migration.Id, n) + } + }) +} diff --git a/internal/push/provider/fcm_provider.go b/internal/push/provider/fcm_provider.go new file mode 100644 index 0000000..61e74f1 --- /dev/null +++ b/internal/push/provider/fcm_provider.go @@ -0,0 +1,76 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + + "allaboutapps.dev/aw/go-starter/internal/push" + "google.golang.org/api/fcm/v1" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" +) + +type FCM struct { + Config FCMConfig + service *fcm.Service +} + +type FCMConfig struct { + GoogleApplicationCredentials string `json:"-"` // sensitive + ProjectID string + ValidateOnly bool +} + +func NewFCM(config FCMConfig, opts ...option.ClientOption) (*FCM, error) { + ctx := context.Background() + fcmService, err := fcm.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create FCM service: %w", err) + } + + return &FCM{ + Config: config, + service: fcmService, + }, nil +} + +func (p *FCM) GetProviderType() push.ProviderType { + return push.ProviderTypeFCM +} + +func (p *FCM) Send(token string, title string, message string) push.ProviderSendResponse { + // https: //godoc.org/google.golang.org/api/fcm/v1#SendMessageRequest + // https://firebase.google.com/docs/cloud-messaging/send-message#rest + messageRequest := &fcm.SendMessageRequest{ + ValidateOnly: p.Config.ValidateOnly, + Message: &fcm.Message{ + Token: token, + Notification: &fcm.Notification{ + Title: title, + Body: message, + }, + }, + } + + _, err := p.service.Projects.Messages.Send("projects/"+p.Config.ProjectID, messageRequest).Do() + valid := true + if err != nil { + // convert to original error and determine if the token was at fault + var gErr *googleapi.Error + if errors.As(err, &gErr) { + valid = (gErr.Code != http.StatusNotFound && gErr.Code != http.StatusBadRequest) + } + } + + return push.ProviderSendResponse{ + Token: token, + Valid: valid, + Err: err, + } +} + +func (p *FCM) SendMulticast(tokens []string, title, message string) []push.ProviderSendResponse { + return sendMulticastWithProvider(p, tokens, title, message) +} diff --git a/internal/push/provider/helper.go b/internal/push/provider/helper.go new file mode 100644 index 0000000..1f333df --- /dev/null +++ b/internal/push/provider/helper.go @@ -0,0 +1,13 @@ +package provider + +import "allaboutapps.dev/aw/go-starter/internal/push" + +func sendMulticastWithProvider(p push.Provider, tokens []string, title, message string) []push.ProviderSendResponse { + responseSlice := make([]push.ProviderSendResponse, 0) + + for _, token := range tokens { + responseSlice = append(responseSlice, p.Send(token, title, message)) + } + + return responseSlice +} diff --git a/internal/push/provider/mock_provider.go b/internal/push/provider/mock_provider.go new file mode 100644 index 0000000..bef6d04 --- /dev/null +++ b/internal/push/provider/mock_provider.go @@ -0,0 +1,51 @@ +package provider + +import ( + "errors" + + "allaboutapps.dev/aw/go-starter/internal/push" + "github.com/rs/zerolog/log" +) + +type Mock struct { + Type push.ProviderType +} + +func NewMock(providerType push.ProviderType) *Mock { + return &Mock{ + Type: providerType, + } +} + +func (p *Mock) GetProviderType() push.ProviderType { + return p.Type +} + +const ( + expectedTokenLength = 40 +) + +func (p *Mock) Send(token string, title string, message string) push.ProviderSendResponse { + valid := true + var err error + if len(token) < expectedTokenLength { + valid = false + err = errors.New("invalid token") + } + + if title == "other error" { + err = errors.New("other error") + } + + log.Info().Str("token", token).Str("title", title).Str("message", message).Msg("Mock Push Notification") + + return push.ProviderSendResponse{ + Token: token, + Valid: valid, + Err: err, + } +} + +func (p *Mock) SendMulticast(tokens []string, title, message string) []push.ProviderSendResponse { + return sendMulticastWithProvider(p, tokens, title, message) +} diff --git a/internal/push/service.go b/internal/push/service.go new file mode 100644 index 0000000..7208a7c --- /dev/null +++ b/internal/push/service.go @@ -0,0 +1,107 @@ +package push + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/data/dto" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util" +) + +type ProviderType string + +const ( + ProviderTypeFCM ProviderType = "fcm" + ProviderTypeAPN ProviderType = "apn" +) + +type Service struct { + DB *sql.DB + provider map[ProviderType]Provider +} + +type ProviderSendResponse struct { + // token the message was sent to + Token string + + // flag to indicate if the token is still valid + // not every error means that the token is invalid + Valid bool + + // ogiginal error + Err error +} + +type Provider interface { + Send(token string, title string, message string) ProviderSendResponse + SendMulticast(tokens []string, title, message string) []ProviderSendResponse + GetProviderType() ProviderType +} + +func New(db *sql.DB) *Service { + return &Service{ + DB: db, + provider: make(map[ProviderType]Provider), + } +} + +func (s *Service) RegisterProvider(p Provider) { + s.provider[p.GetProviderType()] = p +} + +func (s *Service) ResetProviders() { + s.provider = make(map[ProviderType]Provider) +} + +func (s *Service) GetProviderCount() int { + return len(s.provider) +} + +func (s *Service) SendToUser(ctx context.Context, user *dto.User, title string, message string) error { + if s.GetProviderCount() < 1 { + return errors.New("no provider found") + } + log := util.LogFromContext(ctx) + + for providerType, provider := range s.provider { + // get all registered tokens for provider + pushTokens, err := models.PushTokens( + models.PushTokenWhere.Provider.EQ(string(providerType)), + models.PushTokenWhere.UserID.EQ(user.ID), + ).All(ctx, s.DB) + if err != nil { + return fmt.Errorf("failed to get push tokens: %w", err) + } + + var tokens []string + for _, token := range pushTokens { + tokens = append(tokens, token.Token) + } + + responseSlice := provider.SendMulticast(tokens, title, message) + tokenToDelete := make([]string, 0) + for _, res := range responseSlice { + if res.Err != nil && res.Valid { + log.Debug().Err(res.Err).Str("token", res.Token).Str("provider", string(provider.GetProviderType())).Msgf("Error while sending push message to provider with valid token.") + } + + if !res.Valid { + tokenToDelete = append(tokenToDelete, res.Token) + } + } + // delete invalid tokens + _, err = models.PushTokens( + models.PushTokenWhere.Token.IN(tokenToDelete), + models.PushTokenWhere.UserID.EQ(user.ID), + ).DeleteAll(ctx, s.DB) + if err != nil { + log.Debug().Err(err).Str("provider", string(provider.GetProviderType())).Msg("Could not delete invalid tokens for provider") + return err + } + } + + return nil +} diff --git a/internal/push/service_test.go b/internal/push/service_test.go new file mode 100644 index 0000000..0904d80 --- /dev/null +++ b/internal/push/service_test.go @@ -0,0 +1,111 @@ +package push_test + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/data/mapper" + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/push" + "allaboutapps.dev/aw/go-starter/internal/push/provider" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSendMessageSuccess(t *testing.T) { + test.WithTestPusher(t, func(service *push.Service, db *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World") + require.NoError(t, err) + + tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db) + require.NoError(t, err2) + assert.Equal(t, int64(2), tokenCount) + }) +} + +func TestSendMessageSuccessWithGenericError(t *testing.T) { + test.WithTestPusher(t, func(service *push.Service, db *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + // provoke error from mock provider + err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "other error", "World") + require.NoError(t, err) + + tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db) + require.NoError(t, err2) + assert.Equal(t, int64(2), tokenCount) + }) +} + +func TestSendMessageWithInvalidToken(t *testing.T) { + test.WithTestPusher(t, func(service *push.Service, db *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + user1InvalidPushToken := models.PushToken{ + ID: "55c37bc8-f245-40b3-bdef-14dee35b10bd", + Token: "d5ded380-3285-4243-8a9c-72cc3f063fee", + UserID: fix.User1.ID, + Provider: models.ProviderTypeFCM, + } + err := user1InvalidPushToken.Insert(ctx, db, boil.Infer()) + require.NoError(t, err) + + tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db) + require.NoError(t, err2) + require.Equal(t, int64(3), tokenCount) + + err = service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World") + require.NoError(t, err) + + tokenCount, err2 = fix.User1.PushTokens().Count(ctx, db) + require.NoError(t, err2) + assert.Equal(t, int64(2), tokenCount) + }) +} + +func TestSendMessageWithNoProvider(t *testing.T) { + test.WithTestPusher(t, func(service *push.Service, db *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + service.ResetProviders() + require.Equal(t, 0, service.GetProviderCount()) + + err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World") + require.Error(t, err) + + tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db) + require.NoError(t, err2) + assert.Equal(t, int64(2), tokenCount) + }) +} + +func TestSendMessageWithMultipleProvider(t *testing.T) { + test.WithTestPusher(t, func(service *push.Service, db *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + service.ResetProviders() + require.Equal(t, 0, service.GetProviderCount()) + + mockProviderFCM := provider.NewMock(push.ProviderTypeFCM) + mockProviderAPN := provider.NewMock(push.ProviderTypeAPN) + service.RegisterProvider(mockProviderAPN) + service.RegisterProvider(mockProviderFCM) + + err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World") + require.NoError(t, err) + + tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db) + require.NoError(t, err2) + assert.Equal(t, int64(1), tokenCount) + }) +} diff --git a/internal/test/README.md b/internal/test/README.md new file mode 100644 index 0000000..b30a797 --- /dev/null +++ b/internal/test/README.md @@ -0,0 +1,40 @@ +# `/test` + +General global test utilities. + +### Regarding `test/test_*.go` and its `test.With*` functions + +Test helpers like `test.WithTestDatabase` and `test.WithTestServer` require usage of a closure, as these functions automatically manage the setup **and** teardown (e.g. server shutdown, db connection drop) for your testcase. + +Other pkgs don't have this requirement (e.g. the initialization code for `test.NewTestMailer` which covers the setup for the `mailer` mock), thus, please use this `With*` convention incl. closure **only** when it makes sense. + +### Regarding `test/fixtures.go` + +This are your global db test fixtures, that are only available while testing. However, feel free to setup specialized fixtures per package if required (e.g. just initialize an additional IntegreSQL template). + +### Regarding `test/helper_*.go` + +Please use this convention to specify test only utility functions. + +### Regarding snapshot testing + +Golden files can be created by using the `Snapshoter.Save(t TestingT, data ...interface{})` method. The snapshot can be configured to force an update, use a different replacer function or to set a different file location and suffix for the snaphot. + +A snapshot can be updated by either calling the `Update(true)` method, or by using the global override by setting the environment variable `TEST_UPDATE_GOLDEN` to `true`. To update all snapshots the call might look like this: `TEST_UPDATE_GOLDEN=true make test`. + +### `testdata` and `.` or `_` prefixed files + +Note that Go will ignore directories or files that begin with "." or "_", so you have more flexibility in terms of how you name your test data directory. + +> Go build ignores directory named testdata. +> The Go tool will ignore any directory in your $GOPATH that starts with a period, an underscore, or matches the word testdata +> When go test runs, it sets current directory as package directory + +* https://github.com/golang-standards/project-layout/blob/master/test/README.md +* https://medium.com/@povilasve/go-advanced-tips-tricks-a872503ac859 +* https://dave.cheney.net/2016/05/10/test-fixtures-in-go + +Examples: +* https://github.com/openshift/origin/tree/master/test (test data is in the `/testdata` subdirectory) + +https://github.com/golang-standards/project-layout/tree/master/test \ No newline at end of file diff --git a/internal/test/fixtures/fixtures.go b/internal/test/fixtures/fixtures.go new file mode 100644 index 0000000..ee3e882 --- /dev/null +++ b/internal/test/fixtures/fixtures.go @@ -0,0 +1,172 @@ +package fixtures + +import ( + "context" + "fmt" + "time" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" +) + +const ( + PlainTestUserPassword = "password" + HashedTestUserPassword = "$argon2id$v=19$m=65536,t=1,p=4$RFO8ulg2c2zloG0029pAUQ$2Po6NUIhVCMm9vivVDuzo7k5KVWfZzJJfeXzC+n+row" //nolint:gosec +) + +// Insertable represents a common interface for all model instances so they may be inserted via the Inserts() func +type Insertable interface { + Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error +} + +// The main definition which fixtures are available through Fixtures(). +// Mind the declaration order! The fields get inserted exactly in the order they are declared. +type FixtureMap struct { + User1 *models.User + User1AppUserProfile *models.AppUserProfile + User1AccessToken1 *models.AccessToken + User1RefreshToken1 *models.RefreshToken + User2 *models.User + User2AppUserProfile *models.AppUserProfile + User2AccessToken1 *models.AccessToken + User2RefreshToken1 *models.RefreshToken + UserDeactivated *models.User + UserDeactivatedAppUserProfile *models.AppUserProfile + UserDeactivatedAccessToken1 *models.AccessToken + UserDeactivatedRefreshToken1 *models.RefreshToken + User1PushToken *models.PushToken + User1PushTokenAPN *models.PushToken + UserRequiresConfirmation *models.User + UserRequiresConfirmationAppUserProfile *models.AppUserProfile + UserRequiresConfirmationConfirmationToken *models.ConfirmationToken +} + +// Fixtures returns a function wrapping our fixtures, which tests are allowed to manipulate. +// Each test (which may run concurrently) receives a fresh copy, preventing side effects between test runs. +func Fixtures() FixtureMap { + now := time.Now() + f := FixtureMap{} + + f.User1 = &models.User{ + ID: "f6ede5d8-e22a-4ca5-aa12-67821865a3e5", + IsActive: true, + Username: null.StringFrom("user1@example.com"), + Password: null.StringFrom(HashedTestUserPassword), + Scopes: []string{"app"}, + } + + f.User1AppUserProfile = &models.AppUserProfile{ + UserID: f.User1.ID, + LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)), + } + + f.User1AccessToken1 = &models.AccessToken{ + Token: "1cfc27d7-a178-4051-802b-f3ff3967c95c", + ValidUntil: now.Add(10 * 365 * 24 * time.Hour), + UserID: f.User1.ID, + } + + f.User1RefreshToken1 = &models.RefreshToken{ + Token: "66412eaf-2b89-404d-bbb5-46c3b8bf1a53", + UserID: f.User1.ID, + } + + f.User2 = &models.User{ + ID: "76a79a2b-fbd8-45a0-b35b-671a28a87acf", + IsActive: true, + Username: null.StringFrom("user2@example.com"), + Password: null.StringFrom(HashedTestUserPassword), + Scopes: []string{"app"}, + } + + f.User2AppUserProfile = &models.AppUserProfile{ + UserID: f.User2.ID, + LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)), + } + + f.User2AccessToken1 = &models.AccessToken{ + Token: "115d28c5-f585-4fb5-9656-fb321739fee5", + ValidUntil: now.Add(10 * 365 * 24 * time.Hour), + UserID: f.User2.ID, + } + + f.User2RefreshToken1 = &models.RefreshToken{ + Token: "ea909c75-63d1-4348-a63c-4bcf8ab334a2", + UserID: f.User2.ID, + } + + f.UserRequiresConfirmation = &models.User{ + ID: "402e1669-fff7-43ca-8f08-071c06409479", + IsActive: false, + RequiresConfirmation: true, + Username: null.StringFrom("userrequiresconfirmation@example.com"), + Password: null.StringFrom(HashedTestUserPassword), + Scopes: []string{"app"}, + } + + f.UserRequiresConfirmationAppUserProfile = &models.AppUserProfile{ + UserID: f.UserRequiresConfirmation.ID, + LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)), + } + + f.UserRequiresConfirmationConfirmationToken = &models.ConfirmationToken{ + Token: "c9182e0b-4a46-4825-9f10-56f04a8b1665", + ValidUntil: now.Add(time.Hour), + UserID: f.UserRequiresConfirmation.ID, + } + + f.UserDeactivated = &models.User{ + ID: "d9c0dee9-239e-4323-979a-a5354d289627", + IsActive: false, + Username: null.StringFrom("userdeactivated@example.com"), + Password: null.StringFrom(HashedTestUserPassword), + Scopes: []string{"app"}, + } + + f.UserDeactivatedAppUserProfile = &models.AppUserProfile{ + UserID: f.UserDeactivated.ID, + LegalAcceptedAt: null.Time{}, + } + + f.UserDeactivatedAccessToken1 = &models.AccessToken{ + Token: "24d0b38d-387c-400c-80fc-a71d85031d4c", + ValidUntil: now.Add(10 * 365 * 24 * time.Hour), + UserID: f.UserDeactivated.ID, + } + + f.UserDeactivatedRefreshToken1 = &models.RefreshToken{ + Token: "b6e13a88-7b18-4f17-b819-71b196be2444", + UserID: f.UserDeactivated.ID, + } + + f.User1PushToken = &models.PushToken{ + ID: "98ad176b-af90-44b7-b991-d9ebfc5dd9a0", + Token: "cQ_Qk3ZCCZelUZ_K_Yn2BV:APA91bG4jst5srGYZqBAn_wRfiJUzAOQ4k8tV0sDcV4uas2ln5wNwkE_ebneR5Fqk7GvndZ-h3mWnjWaI8yZ4sVwo8qu_Aztotqup4mlEPNYgFGqTlJ5ltQrJG5oKp4RoYQ_0CeFaymn", + UserID: f.User1.ID, + Provider: models.ProviderTypeFCM, + } + + f.User1PushTokenAPN = &models.PushToken{ + ID: "5909b472-86f8-4d15-bb63-d49f4fad41a3", + Token: "0a863a72-d391-4217-9f26-388801684744", + UserID: f.User1.ID, + Provider: models.ProviderTypeApn, + } + + return f +} + +// Inserts defines the order in which the fixtures will be inserted +// into the test database +func Inserts() []Insertable { + fix := Fixtures() + insertableIfc := (*Insertable)(nil) + inserts, err := util.GetFieldsImplementing(&fix, insertableIfc) + if err != nil { + panic(fmt.Errorf("failed to get insertable fixture fields: %w", err)) + } + + return inserts +} diff --git a/internal/test/fixtures/fixtures_test.go b/internal/test/fixtures/fixtures_test.go new file mode 100644 index 0000000..79ce800 --- /dev/null +++ b/internal/test/fixtures/fixtures_test.go @@ -0,0 +1,97 @@ +package fixtures_test + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + _ "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFixturesReload(t *testing.T) { + test.WithTestDatabase(t, func(db *sql.DB) { + err := fixtures.Fixtures().User1.Reload(t.Context(), db) + require.NoError(t, err) + }) +} + +func TestInsert(t *testing.T) { + test.WithTestDatabase(t, func(db *sql.DB) { + userNew := models.User{ + ID: "6d00d09b-fab3-43d8-a163-279fe7ba533e", + IsActive: true, + Username: null.StringFrom("userNew@example.com"), + Password: null.StringFrom("$argon2id$v=19$m=65536,t=1,p=4$RFO8ulg2c2zloG0029pAUQ$2Po6NUIhVCMm9vivVDuzo7k5KVWfZzJJfeXzC+n+row"), + Scopes: []string{"app"}, + } + + err := userNew.Insert(t.Context(), db, boil.Infer()) + require.NoError(t, err) + }) +} + +func TestUpdate(t *testing.T) { + test.WithTestDatabase(t, func(db *sql.DB) { + originalUser1 := fixtures.Fixtures().User1 + + updatedUser1 := *originalUser1 + + updatedUser1.Username = null.StringFrom("user_updated@example.com") + + if updatedUser1.Username == originalUser1.Username { + t.Fatalf("names match!") + } + + _, err := updatedUser1.Update(t.Context(), db, boil.Infer()) + + if err != nil { + t.Error("failed to update") + } + + // Attention, this actually mutates our user1 fixture!!! + err = originalUser1.Reload(t.Context(), db) + + if err != nil { + t.Error("failed to reload") + } + + if updatedUser1.Username != originalUser1.Username { + t.Fatalf("names don't match!") + } + }) + + // with another testdatabase: + test.WithTestDatabase(t, func(db *sql.DB) { + originalUser1 := fixtures.Fixtures().User1 + + // ensure our fixture is the same again! + if originalUser1.Username != null.StringFrom("user1@example.com") { + err := originalUser1.Reload(t.Context(), db) + + if err != nil { + t.Error("failed to reload") + } + + if originalUser1.Username != null.StringFrom("user1@example.com") { + t.Fatalf("fixture even not the same after reload!") + } + + t.Fatalf("fixture was modified!") + } + }) +} + +func TestInsertableInterface(t *testing.T) { + var user any = &models.AppUserProfile{ + UserID: "62b13d29-5c4e-420e-b991-a631d3938776", + } + + _, ok := user.(fixtures.Insertable) + assert.True(t, ok, "AppUserProfile should implement the Insertable interface") +} diff --git a/internal/test/helper_compare.go b/internal/test/helper_compare.go new file mode 100644 index 0000000..9941915 --- /dev/null +++ b/internal/test/helper_compare.go @@ -0,0 +1,88 @@ +package test + +import ( + "crypto/sha256" + "fmt" + "io" + "net/http/httptest" + "os" + "strings" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func CompareFileHashes(t *testing.T, expectedFilePath string, actualFilePath string) { + t.Helper() + + expectedFile, err := os.Open(expectedFilePath) + require.NoError(t, err) + defer expectedFile.Close() + + expectedHash := sha256.New() + _, err = io.Copy(expectedHash, expectedFile) + require.NoError(t, err) + + actualFile, err := os.Open(actualFilePath) + require.NoError(t, err) + defer actualFile.Close() + + actualhash := sha256.New() + _, err = io.Copy(actualhash, actualFile) + require.NoError(t, err) + + assert.Equal(t, expectedHash.Sum(nil), actualhash.Sum(nil)) +} + +func CompareAllPayload(t *testing.T, base map[string]interface{}, toCheck map[string]string, skipKeys map[string]bool, keyConvertFunc ...func(string) string) { + t.Helper() + + var keyFunc func(string) string + if len(keyConvertFunc) > 0 { + keyFunc = keyConvertFunc[0] + } else { + keyFunc = func(s string) string { + return s + } + } + for key, val := range base { + if skipKeys[key] { + continue + } + + // checks with contains because of dateTime and null.String struct + contains := strings.Contains(toCheck[keyFunc(key)], fmt.Sprintf("%v", val)) + assert.Truef(t, contains, "Expected for %s: '%s'. Got: '%s'", key, val, toCheck[keyFunc(key)]) + } +} + +func CompareAll(t *testing.T, base map[string]string, toCheck map[string]string, skipKeys map[string]bool) { + t.Helper() + + for key, val := range base { + if skipKeys[key] { + continue + } + + // checks with contains because of dateTime and null.String struct + contains := strings.Contains(toCheck[key], val) + assert.Truef(t, contains, "Expected for %s: '%s'. Got: '%s'", key, val, toCheck[key]) + } +} + +func RequireHTTPError(t *testing.T, res *httptest.ResponseRecorder, httpError *httperrors.HTTPError) httperrors.HTTPError { + t.Helper() + + if httpError.Code != nil { + require.Equal(t, int(*httpError.Code), res.Result().StatusCode) + } + + var response httperrors.HTTPError + ParseResponseAndValidate(t, res, &response) + + require.Equal(t, httpError, &response) + + return response +} diff --git a/internal/test/helper_compare_test.go b/internal/test/helper_compare_test.go new file mode 100644 index 0000000..a25b3e9 --- /dev/null +++ b/internal/test/helper_compare_test.go @@ -0,0 +1,97 @@ +package test_test + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/stretchr/testify/require" +) + +func TestCompareFileHashes(t *testing.T) { + tmpDir := t.TempDir() + newFilePath := tmpDir + "example2.jpg" + filePath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg") + + file1, err := os.Open(filePath) + require.NoError(t, err) + defer file1.Close() + + file2, err := os.Create(newFilePath) + require.NoError(t, err) + defer file2.Close() + + _, err = io.Copy(file2, file1) + require.NoError(t, err) + require.FileExists(t, newFilePath) + + test.CompareFileHashes(t, filePath, newFilePath) +} + +func TestCompareAllPayload(t *testing.T) { + payload := test.GenericPayload{ + "A": 1, + "B": "b", + "C": 2.3, + "D": true, + "E": "2020-02-01", + "F": conv.UUID4(strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e")), + "X_Y": "skipped", + } + response := map[string]string{ + "A": "1", + "B": "b", + "C": "2.3", + "D": "true", + "E": util.Date(2020, 2, 1, time.UTC).String(), + "F": "0862573e-6ccb-4684-847d-276d3364e91e", + } + + toSkip := map[string]bool{ + "X_Y": true, + } + test.CompareAllPayload(t, payload, response, toSkip) + + payload = test.GenericPayload{ + "a": 1, + "B": "b", + "C": 2.3, + "d": true, + "e": "2020-02-01", + "F": conv.UUID4(strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e")), + "X_Y": "skipped", + } + test.CompareAllPayload(t, payload, response, toSkip, strings.ToUpper) +} + +func TestCompareAll(t *testing.T) { + payload := map[string]string{ + "A": "1", + "B": "b", + "C": "2.3", + "D": "true", + "E": "2020-02-01", + "F": strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e").String(), + "X_Y": "skipped", + } + response := map[string]string{ + "A": "1", + "B": "b", + "C": "2.3", + "D": "true", + "E": util.Date(2020, 2, 1, time.UTC).String(), + "F": "0862573e-6ccb-4684-847d-276d3364e91e", + } + + toSkip := map[string]bool{ + "X_Y": true, + } + test.CompareAll(t, payload, response, toSkip) +} diff --git a/internal/test/helper_dot_env.go b/internal/test/helper_dot_env.go new file mode 100644 index 0000000..67ecc04 --- /dev/null +++ b/internal/test/helper_dot_env.go @@ -0,0 +1,41 @@ +package test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/util" +) + +// DotEnvLoadLocalOrSkipTest tries to load the `.env.local` file in the projectroot +// overwriting ENV vars. If the file is not found, the test will be automatically skipped. +func DotEnvLoadLocalOrSkipTest(t *testing.T) { + t.Helper() + + absolutePathToEnvFile := filepath.Join(util.GetProjectRootDir(), ".env.local") + DotEnvLoadFileOrSkipTest(t, absolutePathToEnvFile) +} + +// DotEnvLoadFileOrSkipTest tries to load the overgiven path to the dotenv file overwriting +// ENV vars. If the file is not found, the test will be automatically skipped. +func DotEnvLoadFileOrSkipTest(t *testing.T, absolutePathToEnvFile string) { + t.Helper() + + // this test should be automatically skipped if no default `.env.local` file was found. + err := config.DotEnvLoad( + absolutePathToEnvFile, + func(k string, v string) error { t.Setenv(k, v); return nil }) + + if err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Skip(absolutePathToEnvFile, "not found, skipping test.") + } else { + t.Fatal(err) + } + } else { + t.Log(absolutePathToEnvFile, "override ENV variables!") + } +} diff --git a/internal/test/helper_dot_env_test.go b/internal/test/helper_dot_env_test.go new file mode 100644 index 0000000..fa08d7d --- /dev/null +++ b/internal/test/helper_dot_env_test.go @@ -0,0 +1,25 @@ +package test_test + +import ( + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +// This test will run or skip depending upon if you currently have a `.env.local` in your project directory +// Note: We do not activate this test in the go-starter template, as it would always be skipped. +// func TestDotEnvLoadLocalOrSkipTest(t *testing.T) { +// test.DotEnvLoadLocalOrSkipTest(t) +// } + +// This test will always run as the /internal/test/testdata/.env.test.local is checked into git. +func TestDotEnvLoadFileOrSkipTest(t *testing.T) { + // explicitly load a (test) dotenv file before getting a new config (for a testserver) + test.DotEnvLoadFileOrSkipTest(t, filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata/.env.test.local")) + c := config.DefaultServiceConfigFromEnv() + assert.Equal(t, "http://overwritten.dotenv.frontend.url.tld:3000", c.Frontend.BaseURL) +} diff --git a/internal/test/helper_files.go b/internal/test/helper_files.go new file mode 100644 index 0000000..2031b9e --- /dev/null +++ b/internal/test/helper_files.go @@ -0,0 +1,62 @@ +package test + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/require" +) + +// PrepareTestFile copies a test file by name from test/testdata/ to a folder unique to the test. +// Used for tests with document to load reference files by fixtures. +func PrepareTestFile(t *testing.T, fileName string, destFileName ...string) { + t.Helper() + + src, err := os.Open(filepath.Join(util.GetProjectRootDir(), "test", "testdata", fileName)) + require.NoError(t, err) + defer src.Close() + + dest := fileName + if len(destFileName) > 0 { + dest = destFileName[0] + } + + path := filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name()), "documents", dest) + err = os.MkdirAll(filepath.Dir(path), 0755) + require.NoError(t, err) + + dst, err := os.Create(path) + require.NoError(t, err) + defer dst.Close() + + _, err = io.Copy(dst, src) + require.NoError(t, err) +} + +// CleanupTestFiles removes folder unique to the test if exists +func CleanupTestFiles(t *testing.T) { + t.Helper() + + err := os.RemoveAll(filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name()))) + require.NoError(t, err) +} + +// WithTempDir creates a folder unique to the tests and ensures cleanup of the folder will be +// performed after the fn got called. +func WithTempDir(t *testing.T, testFunc func(localBasePath string, basePath string)) { + t.Helper() + + localBasePath := filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name())) + basePath := "/documents" + path := filepath.Join(localBasePath, basePath) + err := os.MkdirAll(filepath.Dir(path), 0755) + require.NoError(t, err) + + defer CleanupTestFiles(t) + + testFunc(localBasePath, basePath) +} diff --git a/internal/test/helper_files_test.go b/internal/test/helper_files_test.go new file mode 100644 index 0000000..88c4490 --- /dev/null +++ b/internal/test/helper_files_test.go @@ -0,0 +1,31 @@ +package test_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrepareTestFile(t *testing.T) { + var path string + test.WithTempDir(t, func(localBasePath, basePath string) { + assert.True(t, strings.HasSuffix(localBasePath, strings.ToLower(t.Name()))) + assert.NotEmpty(t, basePath) + + fileName := "example.jpg" + test.PrepareTestFile(t, fileName) + + path = filepath.Join(localBasePath, basePath, fileName) + _, err := os.Stat(path) + require.NoError(t, err) + }) + + _, err := os.Stat(path) + require.Error(t, err) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/internal/test/helper_mappers.go b/internal/test/helper_mappers.go new file mode 100644 index 0000000..ee16e5a --- /dev/null +++ b/internal/test/helper_mappers.go @@ -0,0 +1,64 @@ +package test + +import ( + "fmt" + "reflect" + "strings" +) + +// GetMapFromStructByTag returns a map of a given struct using a tag name as key and +// the string of the property value as value. +// inspired by: https://stackoverflow.com/questions/55879028/golang-get-structs-field-name-by-json-tag +func GetMapFromStructByTag(tag string, input any) map[string]string { + res := make(map[string]string) + + inputType := reflect.TypeOf(input) + if inputType.Kind() != reflect.Struct { + return res + } + + val := reflect.ValueOf(input) + + for i := 0; i < inputType.NumField(); i++ { + f := inputType.Field(i) + tagvalue := strings.Split(f.Tag.Get(tag), ",")[0] // use split to ignore tag "options" like omitempty, etc. + if tagvalue == "" { + continue + } + field := val.Field(i) + if field.Kind() == reflect.Ptr { + if !field.IsNil() { + res[tagvalue] = fmt.Sprintf("%v", field.Elem().Interface()) + } + } else { + res[tagvalue] = fmt.Sprintf("%v", field.Interface()) + } + } + + return res +} + +func GetMapFromStruct(input any) map[string]string { + res := make(map[string]string) + + inputType := reflect.TypeOf(input) + if inputType.Kind() != reflect.Struct { + return res + } + + val := reflect.ValueOf(input) + + for i := 0; i < inputType.NumField(); i++ { + field := inputType.Field(i) + fieldValue := val.Field(i) + if fieldValue.Kind() == reflect.Ptr { + if !fieldValue.IsNil() { + res[field.Name] = fmt.Sprintf("%v", fieldValue.Elem().Interface()) + } + } else { + res[field.Name] = fmt.Sprintf("%v", fieldValue.Interface()) + } + } + + return res +} diff --git a/internal/test/helper_mappers_test.go b/internal/test/helper_mappers_test.go new file mode 100644 index 0000000..2e644b2 --- /dev/null +++ b/internal/test/helper_mappers_test.go @@ -0,0 +1,81 @@ +package test_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/go-openapi/swag" + "github.com/stretchr/testify/assert" +) + +func TestGetMapFromStruct(t *testing.T) { + type tmp struct { + A string + B int + C interface{} + D float32 + E bool + F *string + G *int + } + + data := tmp{ + A: "string", + B: 1, + C: tmp{ + A: "string2", + }, + D: 2.3, + E: true, + F: swag.String("stringPtr"), + G: swag.Int(5), + } + + xMap := test.GetMapFromStruct(data) + assert.Len(t, xMap, 7) + assert.Equal(t, "string", xMap["A"]) + assert.Equal(t, "1", xMap["B"]) + assert.Contains(t, xMap["C"], "string2") + assert.Equal(t, "2.3", xMap["D"]) + assert.Equal(t, "true", xMap["E"]) + assert.Equal(t, "stringPtr", xMap["F"]) + assert.Equal(t, "5", xMap["G"]) +} + +func TestGetMapFromStructByTag(t *testing.T) { + type tmp struct { + A string `x:"1,omitempty" y:"2"` + B int `x:"3"` + C interface{} `x:"12"` + D float32 `x:"2"` + E bool `x:"5"` + F *string `x:"4"` + G *int `x:"6"` + } + + data := tmp{ + A: "string", + B: 1, + C: tmp{ + A: "string2", + }, + D: 2.3, + E: true, + F: swag.String("stringPtr"), + G: swag.Int(5), + } + + xMap := test.GetMapFromStructByTag("x", data) + assert.Len(t, xMap, 7) + assert.Equal(t, "string", xMap["1"]) + assert.Equal(t, "1", xMap["3"]) + assert.Contains(t, xMap["12"], "string2") + assert.Equal(t, "2.3", xMap["2"]) + assert.Equal(t, "true", xMap["5"]) + assert.Equal(t, "stringPtr", xMap["4"]) + assert.Equal(t, "5", xMap["6"]) + + yMap := test.GetMapFromStructByTag("y", data) + assert.Len(t, yMap, 1) + assert.Equal(t, "string", yMap["2"]) +} diff --git a/internal/test/helper_request.go b/internal/test/helper_request.go new file mode 100644 index 0000000..063047a --- /dev/null +++ b/internal/test/helper_request.go @@ -0,0 +1,139 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "allaboutapps.dev/aw/go-starter/internal/api" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/labstack/echo/v4" +) + +type GenericPayload map[string]interface{} +type GenericArrayPayload []interface{} + +func (g GenericPayload) Reader(t TestingT) *bytes.Reader { + t.Helper() + + b, err := json.Marshal(g) + if err != nil { + t.Fatalf("failed to serialize payload: %v", err) + } + + return bytes.NewReader(b) +} + +func (g GenericArrayPayload) Reader(t TestingT) *bytes.Reader { + t.Helper() + + b, err := json.Marshal(g) + if err != nil { + t.Fatalf("failed to serialize payload: %v", err) + } + + return bytes.NewReader(b) +} + +func PerformRequestWithParams(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { + t.Helper() + + if body == nil { + return PerformRequestWithRawBody(t, s, method, path, nil, headers, queryParams) + } + + return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams) +} + +func PerformRequestWithArrayAndParams(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { + t.Helper() + + if body == nil { + return PerformRequestWithRawBody(t, s, method, path, nil, headers, queryParams) + } + + return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams) +} + +func PerformRequestWithRawBody(t TestingT, s *api.Server, method string, path string, body io.Reader, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(method, path, body) + + if headers != nil { + req.Header = headers + } + if body != nil && len(req.Header.Get(echo.HeaderContentType)) == 0 { + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } + + if queryParams != nil { + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + + req.URL.RawQuery = q.Encode() + } + + res := httptest.NewRecorder() + + s.Echo.ServeHTTP(res, req) + + return res +} + +func PerformRequest(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header) *httptest.ResponseRecorder { + t.Helper() + + return PerformRequestWithParams(t, s, method, path, body, headers, nil) +} + +func PerformRequestWithArray(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header) *httptest.ResponseRecorder { + t.Helper() + + return PerformRequestWithArrayAndParams(t, s, method, path, body, headers, nil) +} + +func ParseResponseBody(t TestingT, res *httptest.ResponseRecorder, v interface{}) { + t.Helper() + + if err := json.NewDecoder(res.Result().Body).Decode(&v); err != nil { + t.Fatalf("Failed to parse response body: %v", err) + } +} + +func ParseResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) { + t.Helper() + + ParseResponseBody(t, res, &v) + + if err := v.Validate(strfmt.Default); err != nil { + t.Fatalf("Failed to validate response: %v", err) + } +} + +func HeadersWithAuth(t TestingT, token string) http.Header { + t.Helper() + + return HeadersWithConfigurableAuth(t, "Bearer", token) +} + +func HeadersWithConfigurableAuth(t TestingT, scheme string, token string) http.Header { + t.Helper() + + headers := http.Header{} + headers.Set(echo.HeaderAuthorization, fmt.Sprintf("%s %s", scheme, token)) + + return headers +} + +func HeadersWithAPIKeyAuth(t TestingT, token string) http.Header { + t.Helper() + + return HeadersWithConfigurableAuth(t, "APIKey", token) +} diff --git a/internal/test/helper_snapshot.go b/internal/test/helper_snapshot.go new file mode 100644 index 0000000..45b42b3 --- /dev/null +++ b/internal/test/helper_snapshot.go @@ -0,0 +1,354 @@ +package test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/util" + + "github.com/davecgh/go-spew/spew" + "github.com/go-openapi/runtime" + "github.com/pmezard/go-difflib/difflib" +) + +var ( + DefaultSnapshotDirPathAbs = filepath.Join(util.GetProjectRootDir(), "/test/testdata/snapshots") + UpdateGoldenGlobal = util.GetEnvAsBool("TEST_UPDATE_GOLDEN", false) +) + +var defaultReplacer = func(s string) string { + return s +} + +var spewConfig = spew.ConfigState{ + Indent: " ", + SortKeys: true, // maps should be spewed in a deterministic order + DisablePointerAddresses: true, // don't spew the addresses of pointers + DisableCapacities: true, // don't spew capacities of collections + SpewKeys: true, // if unable to sort map keys then spew keys to strings and sort those +} + +type snapshoter struct { + update bool + label string + replacer func(s string) string + location string + skips []string +} + +var Snapshoter = snapshoter{ + update: false, + label: "", + replacer: defaultReplacer, + location: DefaultSnapshotDirPathAbs, +} + +// Save creates a formatted dump of the given data. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +// vastly inspired by https://github.com/bradleyjkemp/cupaloy +// main reason for self implementation is the replacer function and general flexibility +func (s snapshoter) Save(t TestingT, data ...interface{}) { + t.Helper() + err := os.MkdirAll(s.location, os.ModePerm) + if err != nil { + t.Fatal(err) + } + + dump := s.replacer(spewConfig.Sdump(data...)) + + s.save(t, dump) +} + +// Save creates a dump of the given data. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +// vastly inspired by https://github.com/bradleyjkemp/cupaloy +// main reason for self implementation is the replacer function and general flexibility +func (s snapshoter) SaveBytes(t TestingT, data []byte, fileExtensionOverride ...string) { + t.Helper() + err := os.MkdirAll(s.location, os.ModePerm) + if err != nil { + t.Fatal(err) + } + + s.saveBytes(t, data, fileExtensionOverride...) +} + +// SaveJSON creates a dump of the given data as JSON. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +// vastly inspired by https://github.com/bradleyjkemp/cupaloy +// main reason for self implementation is the replacer function and general flexibility +func (s snapshoter) SaveJSON(t TestingT, data any) { + t.Helper() + + // marshal data + marshaled, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + // indent data + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, marshaled, "", "\t"); err != nil { + t.Fatal(err) + } + + jsonS := s + // set custom replacer for JSON compared to dumps + jsonS.replacer = func(target string) string { + skipString := strings.Join(jsonS.skips, "|") + re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString)) + if err != nil { + panic(err) + } + + // replace lines with property name + + return re.ReplaceAllString(target, `"$1": ,`) + } + + jsonS.label += "JSON" + jsonS.SaveString(t, prettyJSON.String()) +} + +// SaveUJSON is a short version for .Update(true).SaveJSON(...) +func (s snapshoter) SaveUJSON(t TestingT, data any) { + t.Helper() + s.Update(true).SaveJSON(t, data) +} + +// SaveString creates a snapshot of the raw string. +// Used to snapshot payloads or mails as formatted data. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +// vastly inspired by https://github.com/bradleyjkemp/cupaloy +// main reason for self implementation is the replacer function and general flexibility +func (s snapshoter) SaveString(t TestingT, data string) { + t.Helper() + err := os.MkdirAll(s.location, os.ModePerm) + if err != nil { + t.Fatal(err) + } + + data = s.replacer(data) + + s.save(t, data) +} + +func (s snapshoter) SaveUString(t TestingT, data string) { + t.Helper() + s.Update(true).save(t, data) +} + +// SaveResponseAndValidate is used to create 2 snapshots for endpoint tests. +// One snapshot will save the raw JSON response as indented JSON. +// For the second snapshot the response will be parsed and validated using request helpers (helper_request.go) +// Afterwards a dump of the response will be saved. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +func (s snapshoter) SaveResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) { + t.Helper() + + // snapshot prettyfied json first + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, res.Body.Bytes(), "", "\t"); err != nil { + t.Fatal(err) + } + + jsonS := s + // set custom replacer for JSON compared to dumps + jsonS.replacer = func(target string) string { + skipString := strings.Join(jsonS.skips, "|") + re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString)) + if err != nil { + panic(err) + } + + // replace lines with property name + + return re.ReplaceAllString(target, `"$1": ,`) + } + + jsonS.label += "JSON" + jsonS.SaveString(t, prettyJSON.String()) + + // bind and snapshot response type struct + ParseResponseAndValidate(t, res, v) + s.Save(t, v) +} + +func (s snapshoter) SaveUResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) { + t.Helper() + s.Update(true).SaveResponseAndValidate(t, res, v) +} + +// SaveU is a short version for .Update(true).Save(...) +func (s snapshoter) SaveU(t TestingT, data ...interface{}) { + t.Helper() + s.Update(true).Save(t, data...) +} + +// Skip creates a custom replace function using a regex, this will replace any +// replacer function set in the Snapshoter. +// Each line of the formatted dump is matched against the property name defined in skip and +// the value will be replaced to deal with generated values that change each test. +func (s snapshoter) Skip(skip []string) snapshoter { + s.skips = skip + s.replacer = func(target string) string { + skipString := fmt.Sprintf("\\s+%s", strings.Join(skip, "|\\s+")) + + re, err := regexp.Compile(fmt.Sprintf("(?m)(%s): .*[^{]$", skipString)) + if err != nil { + panic(err) + } + + reStruct, err := regexp.Compile(fmt.Sprintf("((%s): .*){\n([^}]|\n)*}", skipString)) + if err != nil { + panic(err) + } + + // replace lines with property name + + return reStruct.ReplaceAllString(re.ReplaceAllString(target, "$1: ,"), "$1 { }") + } + + return s +} + +// Redact is a wrapper for Skip for easier usage with a variadic. +func (s snapshoter) Redact(skip ...string) snapshoter { + return s.Skip(skip) +} + +// Upadte is used to force an update for the snapshot. Will fail the test. +func (s snapshoter) Update(update bool) snapshoter { + s.update = update + return s +} + +// Label is used to add a suffix to the snapshots golden file. +func (s snapshoter) Label(label string) snapshoter { + s.label = label + return s +} + +// Replacer is used to define a custom replace function in order to replace +// generated values (e.g. IDs). +func (s snapshoter) Replacer(replacer func(s string) string) snapshoter { + s.replacer = replacer + return s +} + +// Location is used to save the golden file to a different location. +func (s snapshoter) Location(location string) snapshoter { + s.location = location + return s +} + +func (s snapshoter) save(t TestingT, dump string) { + t.Helper() + snapshotName := fmt.Sprintf("%s%s", strings.ReplaceAll(t.Name(), "/", "-"), s.label) + snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.golden", snapshotName)) + + if s.update || UpdateGoldenGlobal { + err := writeSnapshotString(snapshotAbsPath, dump) + if err != nil { + t.Fatal(err) + } + + t.Errorf("Updating snapshot: '%s'", snapshotName) + return + } + + prevSnapBytes, err := os.ReadFile(snapshotAbsPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = writeSnapshotString(snapshotAbsPath, dump) + if err != nil { + t.Fatal(err) + } + + t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName) + + return + } + + t.Fatal(err) + } + + prevSnap := string(prevSnapBytes) + if prevSnap != dump { + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(prevSnap), + B: difflib.SplitLines(dump), + FromFile: "Previous", + ToFile: "Current", + Context: 1, + }) + if err != nil { + t.Fatal(err) + } + + t.Error(fmt.Sprintf("%s: %s", snapshotName, diff)) + } +} + +func (s snapshoter) saveBytes(t TestingT, dump []byte, fileExtensionOverride ...string) { + t.Helper() + snapshotName := fmt.Sprintf("%s%s", strings.ReplaceAll(t.Name(), "/", "-"), s.label) + + fileExtension := "golden" + if len(fileExtensionOverride) > 0 { + fileExtension = fileExtensionOverride[0] + } + + snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.%s", snapshotName, fileExtension)) + + if s.update || UpdateGoldenGlobal { + err := writeSnapshot(snapshotAbsPath, dump) + if err != nil { + t.Fatal(err) + } + + t.Errorf("Updating snapshot: '%s'", snapshotName) + return + } + + prevSnapBytes, err := os.ReadFile(snapshotAbsPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = writeSnapshot(snapshotAbsPath, dump) + if err != nil { + t.Fatal(err) + } + + t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName) + return + } + + t.Fatal(err) + } + + if !bytes.Equal(prevSnapBytes, dump) { + t.Error(fmt.Sprintf("%s: Byte Snapshot Diff", snapshotName)) + } +} + +func writeSnapshotString(absPath string, dump string) error { + return writeSnapshot(absPath, []byte(dump)) +} + +func writeSnapshot(absPath string, dump []byte) error { + err := os.WriteFile(absPath, dump, os.FileMode(0644)) + if err != nil { + return fmt.Errorf("failed to write snapshot: %w", err) + } + + return nil +} diff --git a/internal/test/helper_snapshot_test.go b/internal/test/helper_snapshot_test.go new file mode 100644 index 0000000..7442484 --- /dev/null +++ b/internal/test/helper_snapshot_test.go @@ -0,0 +1,358 @@ +package test_test + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "regexp" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/test/mocks" + "allaboutapps.dev/aw/go-starter/internal/util" + + apitypes "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/aarondl/sqlboiler/v4/types" + "github.com/go-openapi/swag" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + helloWorld = "Hello World!" +) + +func TestSnapshot(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + data := struct { + A string + B int + C bool + D *string + }{ + A: "foo", + B: 1, + C: true, + D: swag.String("bar"), + } + + test.Snapshoter.Save(t, data, helloWorld) +} + +func TestSnapshotWithReplacer(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + randID, err := util.GenerateRandomBase64String(20) + require.NoError(t, err) + data := struct { + ID string + A string + B int + C bool + D *string + }{ + ID: randID, + A: "foo", + B: 1, + C: true, + D: swag.String("bar"), + } + + replacer := func(s string) string { + re, err := regexp.Compile(`ID:.*"(.*)",`) + require.NoError(t, err) + return re.ReplaceAllString(s, "ID: ,") + } + test.Snapshoter.Replacer(replacer).Save(t, data) +} + +func TestSnapshotShouldFail(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + data := struct { + A string + B int + C bool + D *string + }{ + A: "fo", + B: 1, + C: true, + D: swag.String("bar"), + } + + tMock := new(mocks.TestingT) + tMock.On("Helper").Return() + tMock.On("Name").Return("TestSnapshotShouldFail") + tMock.On("Error", mock.Anything).Return() + test.Snapshoter.Save(tMock, data, helloWorld) + tMock.AssertNotCalled(t, "Fatal") + tMock.AssertNotCalled(t, "Fatalf") + tMock.AssertCalled(t, "Error", mock.Anything) +} + +func TestSnapshotWithUpdate(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + data := struct { + A string + B int + C bool + D *string + }{ + A: "fo", + B: 1, + C: true, + D: swag.String("bar"), + } + + tMock := new(mocks.TestingT) + tMock.On("Helper").Return() + tMock.On("Name").Return("TestSnapshotWithUpdate") + tMock.On("Errorf", mock.Anything, mock.Anything).Return() + test.Snapshoter.Update(true).Save(tMock, data, helloWorld) + tMock.AssertNotCalled(t, "Error") + tMock.AssertNotCalled(t, "Fatal") + tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything) +} + +func TestSnapshotNotExists(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + data := struct { + A string + B int + C bool + D *string + }{ + A: "foo", + B: 1, + C: true, + D: swag.String("bar"), + } + + defer func() { + os.Remove(filepath.Join(test.DefaultSnapshotDirPathAbs, "TestSnapshotNotExists.golden")) + }() + + tMock := new(mocks.TestingT) + tMock.On("Helper").Return() + tMock.On("Name").Return("TestSnapshotNotExists") + tMock.On("Fatalf", mock.Anything, mock.Anything).Return() + tMock.On("Fatal", mock.Anything).Return() + tMock.On("Error", mock.Anything).Return() + tMock.On("Errorf", mock.Anything, mock.Anything).Return() + test.Snapshoter.Save(tMock, data, helloWorld) + tMock.AssertNotCalled(t, "Error") + tMock.AssertNotCalled(t, "Fatal") + tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything) +} + +func TestSnapshotSkipFields(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + randID, err := util.GenerateRandomBase64String(20) + require.NoError(t, err) + data := struct { + ID string + A string + B int + C bool + D *string + }{ + ID: randID, + A: "foo", + B: 1, + C: true, + D: swag.String("bar"), + } + + test.Snapshoter.Skip([]string{"ID"}).Save(t, data) +} + +func TestSnapshotSkipPrefixedFields(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + + data := struct { + ID string + OtherIDStr string + OtherIDInt int + OtherIDBool bool + OtherIDPTR *string + OtherIDStruct struct { + ID string + } + }{ + ID: "foo", + OtherIDStr: "id str", + OtherIDInt: 4, + OtherIDBool: true, + OtherIDPTR: swag.String("ID str ptr"), + OtherIDStruct: struct{ ID string }{ + ID: "foo", + }, + } + + test.Snapshoter.Skip([]string{"ID"}).Save(t, data) +} + +func TestSnapshotSkipMultilineFields(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + randID, err := util.GenerateRandomBase64String(20) + require.NoError(t, err) + data := struct { + ID string + A string + B int + C bool + D interface{} + E []string + F map[string]int + }{ + ID: randID, + A: "foo", + B: 1, + C: true, + D: struct { + Foo string + Bar int + }{ + Foo: "skip me", + Bar: 3, + }, + E: []string{"skip me", "skip me too"}, + F: map[string]int{ + "skip me": 1, + "skip me too": 2, + "skip me three": 3, + }, + } + + test.Snapshoter.Skip([]string{"ID", "D", "E", "F"}).Save(t, data) +} + +func TestSnapshotWithLabel(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + data := struct { + A string + B int + C bool + D *string + }{ + A: "foo", + B: 1, + C: true, + D: swag.String("bar"), + } + + test.Snapshoter.Label("_A").Save(t, data) + test.Snapshoter.Label("_B").Save(t, helloWorld) +} + +func TestSnapshotWithLocation(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + data := struct { + A string + B int + C bool + D *string + }{ + A: "foo", + B: 1, + C: true, + D: swag.String("bar"), + } + + location := filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata") + test.Snapshoter.Location(location).Save(t, data) +} + +func TestSaveResponseAndValidate(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + + test.WithTestServer(t, func(s *api.Server) { + fix := fixtures.Fixtures() + + res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token)) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response apitypes.GetUserInfoResponse + test.Snapshoter.Redact("Email", "UpdatedAt", "updated_at").SaveResponseAndValidate(t, res, &response) + }) +} + +func TestSnapshotJSON(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + + randID, err := util.GenerateRandomBase64String(20) + require.NoError(t, err) + + details := struct { + ID string + A string + B int + C bool + D interface{} + E []string + F map[string]int + }{ + ID: randID, + A: "foo", + B: 1, + C: true, + D: struct { + Foo string + Bar int + }{ + Foo: "skip me", + Bar: 3, + }, + E: []string{"skip me", "skip me too"}, + F: map[string]int{ + "skip me": 1, + "skip me too": 2, + "skip me three": 3, + }, + } + + marshaled, err := json.Marshal(details) + require.NoError(t, err) + + test.Snapshoter.Redact("ID").SaveJSON(t, types.JSON(json.RawMessage(marshaled))) +} + +func TestSnapshotSaveBytesImage(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + + filepath := filepath.Join(util.GetProjectRootDir(), "/test/testdata", "example.jpg") + + // read file and save bytes + content, err := os.ReadFile(filepath) + require.NoError(t, err) + + test.Snapshoter.SaveBytes(t, content, "jpg") +} diff --git a/internal/test/mocks/TestingT.go b/internal/test/mocks/TestingT.go new file mode 100644 index 0000000..4a025d2 --- /dev/null +++ b/internal/test/mocks/TestingT.go @@ -0,0 +1,154 @@ +// Code generated by mockery. DO NOT EDIT. +// Initial code generated by mockery using a local installation. +// The methods for the testing.T interface will not change that often so no +// automatic generation of the mock is required. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// TestingT is an autogenerated mock type for the TestingT type +type TestingT struct { + mock.Mock +} + +// Cleanup provides a mock function with given fields: _a0 +func (_m *TestingT) Cleanup(_a0 func()) { + _m.Called(_a0) +} + +// Error provides a mock function with given fields: args +func (_m *TestingT) Error(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Errorf provides a mock function with given fields: format, args +func (_m *TestingT) Errorf(format string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Fail provides a mock function with given fields: +func (_m *TestingT) Fail() { + _m.Called() +} + +// FailNow provides a mock function with given fields: +func (_m *TestingT) FailNow() { + _m.Called() +} + +// Failed provides a mock function with given fields: +func (_m *TestingT) Failed() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Fatal provides a mock function with given fields: args +func (_m *TestingT) Fatal(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Fatalf provides a mock function with given fields: format, args +func (_m *TestingT) Fatalf(format string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Helper provides a mock function with given fields: +func (_m *TestingT) Helper() { + _m.Called() +} + +// Log provides a mock function with given fields: args +func (_m *TestingT) Log(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Logf provides a mock function with given fields: format, args +func (_m *TestingT) Logf(format string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Name provides a mock function with given fields: +func (_m *TestingT) Name() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Skip provides a mock function with given fields: args +func (_m *TestingT) Skip(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// SkipNow provides a mock function with given fields: +func (_m *TestingT) SkipNow() { + _m.Called() +} + +// Skipf provides a mock function with given fields: format, args +func (_m *TestingT) Skipf(format string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Skipped provides a mock function with given fields: +func (_m *TestingT) Skipped() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// TempDir provides a mock function with given fields: +func (_m *TestingT) TempDir() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} diff --git a/internal/test/test_clock.go b/internal/test/test_clock.go new file mode 100644 index 0000000..f246428 --- /dev/null +++ b/internal/test/test_clock.go @@ -0,0 +1,27 @@ +package test + +import ( + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "github.com/dropbox/godropbox/time2" +) + +func GetMockClock(t *testing.T, clock time2.Clock) *time2.MockClock { + t.Helper() + mc, ok := clock.(*time2.MockClock) + if !ok { + t.Fatalf("invalid clock type, got %T, want *time2.MockClock", clock) + } + + return mc +} + +func SetMockClock(t *testing.T, s *api.Server, time time.Time) { + t.Helper() + + mockClock := GetMockClock(t, s.Clock) + + mockClock.Set(time) +} diff --git a/internal/test/test_clock_test.go b/internal/test/test_clock_test.go new file mode 100644 index 0000000..8f290d3 --- /dev/null +++ b/internal/test/test_clock_test.go @@ -0,0 +1,26 @@ +package test_test + +import ( + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTestClock(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC) + + test.SetMockClock(t, s, now) + + assert.Equal(t, now, s.Clock.Now()) + + clock := test.GetMockClock(t, s.Clock) + require.NotNil(t, clock) + + assert.Equal(t, now, clock.Now()) + }) +} diff --git a/internal/test/test_database.go b/internal/test/test_database.go new file mode 100644 index 0000000..6583f16 --- /dev/null +++ b/internal/test/test_database.go @@ -0,0 +1,382 @@ +package test + +import ( + "context" + "crypto/md5" //nolint:gosec + "database/sql" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + pUtil "allaboutapps.dev/aw/go-starter/internal/util" + dbutil "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/allaboutapps/integresql-client-go" + "github.com/allaboutapps/integresql-client-go/pkg/util" + "github.com/pkg/errors" + migrate "github.com/rubenv/sql-migrate" +) + +var ( + client *integresql.Client + + // tracks IntegreSQL template initialization and hash relookup (to reidentify the pool from a precomputed poolID) + poolInitMap = &sync.Map{} // "poolID" -> *sync.Once + poolHashMap = &sync.Map{} // "poolID" -> "poolHash" + + // we will compute a db template hash over the following dirs/files + migDir = config.DatabaseMigrationFolder + fixFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/fixtures/fixtures.go") + selfFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/test_database.go") + defaultPoolPaths = []string{migDir, fixFile, selfFile} +) + +//nolint:gochecknoinits +func init() { + // autoinitialize IntegreSQL client + c, err := integresql.DefaultClientFromEnv() + if err != nil { + panic(errors.Wrap(err, "Failed to create new integresql-client")) + } + client = c + + // pin migrate to use the globally defined `migrations` table identifier + migrate.SetTable(config.DatabaseMigrationTable) +} + +// WithTestDatabase returns an isolated test database based on the current migrations and fixtures. +func WithTestDatabase(t *testing.T, closure func(db *sql.DB)) { + t.Helper() + ctx := t.Context() + WithTestDatabaseContext(ctx, t, closure) +} + +// WithTestDatabaseContext returns an isolated test database based on the current migrations and fixtures. +// The provided context will be used during setup (instead of the default background context). +func WithTestDatabaseContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) { + t.Helper() + + poolID := strings.Join(defaultPoolPaths, ",") + + // Get a hold of the &sync.Once{} for this integresql pool in this pkg scope... + initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{}) + doOnce, ok := initOnce.(*sync.Once) + if !ok { + t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce) + } + + doOnce.Do(func() { + t.Helper() + + // compute and store poolID -> poolHash map (computes hash of all files/dirs specified) + poolHash := storePoolHash(t, poolID, defaultPoolPaths) + + // properly build up the template database once + execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error { + t.Helper() + + countMigrations, err := ApplyMigrations(t, db) + if err != nil { + t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err) + return err + } + t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash) + + countFixtures, err := ApplyTestFixtures(ctx, t, db) + if err != nil { + t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err) + return err + } + t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash) + + return nil + }) + }) + + // execute closure in a new IntegreSQL database build from above template + execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabase", closure) +} + +type DatabaseDumpConfig struct { + DumpFile string // required, absolute path to dump file + ApplyMigrations bool // optional, default false + ApplyTestFixtures bool // optional, default false +} + +// WithTestDatabaseFromDump returns an isolated test database based on a dump file. +func WithTestDatabaseFromDump(t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) { + t.Helper() + ctx := t.Context() + WithTestDatabaseFromDumpContext(ctx, t, config, closure) +} + +// WithTestDatabaseFromDumpContext returns an isolated test database based on a dump file. +// The provided context will be used during setup (instead of the default background context). +func WithTestDatabaseFromDumpContext(ctx context.Context, t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) { + t.Helper() + + // DumpFile is mandadory. + if config.DumpFile == "" { + t.Fatal("DatabaseDumpConfig.DumpFile is mandadory and cannot be ''") + } + + // poolID must incorporate additional config args in the final hash + fragments := fmt.Sprintf("?migrations=%v&fixtures=%v", config.ApplyMigrations, config.ApplyTestFixtures) + poolID := strings.Join([]string{config.DumpFile, selfFile}, ",") + fragments + + // Get a hold of the &sync.Once{} for this integresql pool in this pkg scope... + initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{}) + doOnce, ok := initOnce.(*sync.Once) + if !ok { + t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce) + } + + doOnce.Do(func() { + t.Helper() + + // compute and store poolID -> poolHash map (computes hash of all files/dirs specified) + poolHash := storePoolHash(t, poolID, []string{config.DumpFile, selfFile}, fragments) + + // properly build up the template database once + execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error { + t.Helper() + + if err := ApplyDump(ctx, t, db, config.DumpFile); err != nil { + t.Fatalf("Failed to apply dumps for %q: %v\n", poolHash, err) + return err + } + t.Logf("Applied dump for hash %q", poolHash) + + if config.ApplyMigrations { + countMigrations, err := ApplyMigrations(t, db) + if err != nil { + t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err) + return err + } + t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash) + } + + if config.ApplyTestFixtures { + countFixtures, err := ApplyTestFixtures(ctx, t, db) + if err != nil { + t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err) + return err + } + t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash) + } + + return nil + }) + }) + + // execute closure in a new IntegreSQL database build from above template + execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseFromDump", closure) +} + +// WithTestDatabaseEmpty returns an isolated test database with no migrations applied or fixtures inserted. +func WithTestDatabaseEmpty(t *testing.T, closure func(db *sql.DB)) { + t.Helper() + ctx := t.Context() + WithTestDatabaseEmptyContext(ctx, t, closure) +} + +// WithTestDatabaseEmptyContext returns an isolated test database with no migrations applied or fixtures inserted. +// The provided context will be used during setup (instead of the default background context). +func WithTestDatabaseEmptyContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) { + t.Helper() + + poolID := selfFile + + // Get a hold of the &sync.Once{} for this integresql pool in this pkg scope... + initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{}) + doOnce, ok := initOnce.(*sync.Once) + if !ok { + t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce) + } + + doOnce.Do(func() { + t.Helper() + + // compute and store poolID -> poolHash map (computes hash of all files/dirs specified) + poolHash := storePoolHash(t, poolID, []string{selfFile}) + + // properly build up the template database once (noop) + execClosureNewIntegresTemplate(ctx, t, poolHash, func(_ *sql.DB) error { + t.Helper() + return nil + }) + }) + + // execute closure in a new IntegreSQL database build from above template + execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseEmpty", closure) +} + +// Adds poolID to poolHashMap pointing to the final integresql hash +// Expects hashPaths to be absolute paths to actual files or directories (its contents will be md5 hashed) +// Optional fragments can be used to further enhance the computed md5 +func storePoolHash(t *testing.T, poolID string, hashPaths []string, fragments ...string) string { + t.Helper() + + // compute a new integreSQL pool hash + poolHash, err := util.GetTemplateHash(hashPaths...) + if err != nil { + t.Fatalf("Failed to create template hash for %v: %#v", poolID, err) + } + + // update the hash with optional provided fragments + if len(fragments) > 0 { + poolHash = fmt.Sprintf("%x", md5.Sum([]byte(poolHash+strings.Join(fragments, ",")))) //nolint:gosec + } + + // and point poolID to it (sideffect synchronized store!) + poolHashMap.Store(poolID, poolHash) // save it for all runners + + return poolHash +} + +// Gets precomputed integresql hash via poolID identifier from our synchronized map (see storePoolHash) +func getPoolHash(t *testing.T, poolID string) string { + t.Helper() + + poolHash, ok := poolHashMap.Load(poolID) + if !ok { + t.Fatalf("Failed to get poolHash from poolID '%v'. Is poolHashMap initialized yet?", poolID) + return "" + } + + hash, ok := poolHash.(string) + if !ok { + t.Fatalf("Expected poolHash to be of type string, but got %T", poolHash) + return "" + } + + return hash +} + +// Executes closure on an integresql **template** database +func execClosureNewIntegresTemplate(ctx context.Context, t *testing.T, poolHash string, closure func(db *sql.DB) error) { + t.Helper() + + if err := client.SetupTemplateWithDBClient(ctx, poolHash, closure); err != nil { + // This error is exceptionally fatal as it hinders ANY future other + // test execution with this hash as the template was *never* properly + // setuped successfully. All GetTestDatabase will wait unti timeout + // unless we interrupt them by discarding the base template... + discardError := client.DiscardTemplate(ctx, poolHash) + + if discardError != nil { + t.Fatalf("Failed to setup template database, also discarding failed for poolHash %q: %v, %v", poolHash, err, discardError) + } + + t.Fatalf("Failed to setup template database (discarded) for poolHash %q: %v", poolHash, err) + } +} + +// Executes closure on an integresql **test** database (scaffolded from a template) +func execClosureNewIntegresDatabase(ctx context.Context, t *testing.T, poolHash string, callee string, closure func(db *sql.DB)) { + t.Helper() + + testDatabase, err := client.GetTestDatabase(ctx, poolHash) + + if err != nil { + t.Fatalf("Failed to obtain test database: %v", err) + } + + connectionString := testDatabase.Config.ConnectionString() + t.Logf("%v: %q", callee, testDatabase.Config.Database) + + db, err := sql.Open("postgres", connectionString) + + if err != nil { + t.Fatalf("Failed to setup test database for connectionString %q: %v", connectionString, err) + } + + if err := db.PingContext(ctx); err != nil { + t.Fatalf("Failed to ping test database for connectionString %q: %v", connectionString, err) + } + + closure(db) + + // this database object is managed and should close automatically after running the test + if err := db.Close(); err != nil { + t.Fatalf("Failed to close db %q: %v", connectionString, err) + } + + // disallow any further refs to managed object after running the test + //nolint: wastedassign + db = nil +} + +// ApplyMigrations applies all current database migrations to db +func ApplyMigrations(t *testing.T, db *sql.DB) (int, error) { + t.Helper() + + // In case an old default sql-migrate migration table (named "gorp_migrations") still exists we rename it to the new name equivalent + // in sync with the settings in dbconfig.yml and config.DatabaseMigrationTable. + if _, err := db.ExecContext(t.Context(), fmt.Sprintf("ALTER TABLE IF EXISTS gorp_migrations RENAME TO %s;", config.DatabaseMigrationTable)); err != nil { + return 0, fmt.Errorf("failed to rename migrations table: %w", err) + } + + migrations := &migrate.FileMigrationSource{Dir: migDir} + countMigrations, err := migrate.Exec(db, "postgres", migrations, migrate.Up) + if err != nil { + return 0, fmt.Errorf("failed to apply migrations: %w", err) + } + + return countMigrations, nil +} + +// ApplyTestFixtures applies all current test fixtures (insert) to db +func ApplyTestFixtures(ctx context.Context, t *testing.T, db *sql.DB) (int, error) { + t.Helper() + + inserts := fixtures.Inserts() + + // insert test fixtures in an auto-managed db transaction + err := dbutil.WithTransaction(ctx, db, func(tx boil.ContextExecutor) error { + t.Helper() + for _, fixture := range inserts { + if err := fixture.Insert(ctx, tx, boil.Infer()); err != nil { + return fmt.Errorf("failed to insert fixture: %w", err) + } + } + return nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to apply test fixtures: %w", err) + } + + return len(inserts), nil +} + +// ApplyDump applies dumpFile (absolute path to .sql file) to db +func ApplyDump(ctx context.Context, t *testing.T, db *sql.DB, dumpFile string) error { + t.Helper() + + // ensure file exists + if _, err := os.Stat(dumpFile); err != nil { + return fmt.Errorf("failed to stat dump file: %w", err) + } + + // we need to get the db name before being able to do anything. + var targetDB string + if err := db.QueryRowContext(ctx, "SELECT current_database();").Scan(&targetDB); err != nil { + return fmt.Errorf("failed to get current database: %w", err) + } + + cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf("cat %q | psql %q", dumpFile, targetDB)) //nolint:gosec + combinedOutput, err := cmd.CombinedOutput() + + if err != nil { + return errors.Wrap(err, string(combinedOutput)) + } + + return nil +} diff --git a/internal/test/test_database_test.go b/internal/test/test_database_test.go new file mode 100644 index 0000000..079ecbd --- /dev/null +++ b/internal/test/test_database_test.go @@ -0,0 +1,229 @@ +package test_test + +import ( + "database/sql" + "fmt" + "path/filepath" + "strings" + "sync" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test" + pUtil "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithTestDatabaseConcurrentUsage(t *testing.T) { + wg := sync.WaitGroup{} + wg.Add(4) + + go func() { + test.WithTestDatabase(t, func(db1 *sql.DB) { + assert.NotNil(t, db1) + wg.Done() + }) + }() + + go func() { + test.WithTestDatabaseEmpty(t, func(db2 *sql.DB) { + assert.NotNil(t, db2) + wg.Done() + }) + }() + + go func() { + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/plain.sql")}, func(db3 *sql.DB) { + assert.NotNil(t, db3) + wg.Done() + }) + }() + + go func() { + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")}, func(db4 *sql.DB) { + assert.NotNil(t, db4) + wg.Done() + }) + }() + + // the above will concurrently write to the database pool maps, + wg.Wait() +} + +func TestWithTestDatabase(t *testing.T) { + test.WithTestDatabase(t, func(db1 *sql.DB) { + test.WithTestDatabase(t, func(db2 *sql.DB) { + var db1Name string + err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name) + if err != nil { + t.Fatal(err) + } + + var db2Name string + err = db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name) + if err != nil { + t.Fatal(err) + } + + require.NotEqual(t, db1Name, db2Name) + }) + }) +} + +func TestWithTestDatabaseFromDump(t *testing.T) { + dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql") + + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db1 *sql.DB) { + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db2 *sql.DB) { + var db1Name string + if err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil { + t.Fatal(err) + } + + var db2Name string + if err := db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil { + t.Fatal(err) + } + + require.NotEqual(t, db1Name, db2Name) + + if _, err := db2.ExecContext(t.Context(), "DELETE FROM users WHERE true;"); err != nil { + t.Fatal(err) + } + + var userCount1 int + if err := db1.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&userCount1); err != nil { + t.Fatal(err) + } + require.Equal(t, 3, userCount1) + + var userCount2 int + if err := db2.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&userCount2); err != nil { + t.Fatal(err) + } + require.Equal(t, 0, userCount2) + }) + }) +} + +func TestWithTestDatabaseFromDumpAutoMigrateAndTestFixtures(t *testing.T) { + dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/plain.sql") + + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db0 *sql.DB) { + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true}, func(db1 *sql.DB) { + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true, ApplyTestFixtures: true}, func(db2 *sql.DB) { + // db0: has only a plain dump + // db1: has migrations + // db2: has migrations and testFixtures + + var db0Name string + if err := db0.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db0Name); err != nil { + t.Fatal(err) + } + + var db1Name string + if err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil { + t.Fatal(err) + } + + var db2Name string + if err := db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil { + t.Fatal(err) + } + + require.NotEqual(t, db0Name, db1Name) + require.NotEqual(t, db1Name, db2Name) + require.NotEqual(t, db2Name, db0Name) + + // expect hash to be different for all 3 databases! + db0Hash := strings.Split(strings.Join(strings.Split(db0Name, "integresql_test_"), ""), "_")[0] + db1Hash := strings.Split(strings.Join(strings.Split(db1Name, "integresql_test_"), ""), "_")[0] + db2Hash := strings.Split(strings.Join(strings.Split(db2Name, "integresql_test_"), ""), "_")[0] + + require.NotEqual(t, db0Hash, db1Hash) + require.NotEqual(t, db1Hash, db2Hash) + require.NotEqual(t, db2Hash, db0Hash) + }) + }) + }) +} + +func TestWithTestDatabaseFromDumpGorp(t *testing.T) { + dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/uuid_extension_only.sql") + + // migrate transforms gorp_migratons to migrations + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true}, func(db *sql.DB) { + // check that we properly renamed the "gorp_migrations" migration tracking table to config.DatabaseMigrationTable + var migrationsTableName string + if err := db.QueryRowContext(t.Context(), fmt.Sprintf("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '%s';", config.DatabaseMigrationTable)).Scan(&migrationsTableName); err != nil { + t.Fatal(err) + } + + require.Equal(t, config.DatabaseMigrationTable, migrationsTableName) + + // check that gorp_migrations does not exist! + var gorp string + err := db.QueryRowContext(t.Context(), "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'gorp_migrations';").Scan(&gorp) + + require.Error(t, err) + + // we can expect that the '20200428064736-install-extension-uuid.sql' migration within the uuid_extension_only.sql is a very stable migrations. + // let's check if other migrations were applied... + var migrationsCount int + err = db.QueryRowContext(t.Context(), "SELECT COUNT(table_name) FROM information_schema.tables WHERE table_schema = 'public';").Scan(&migrationsCount) + require.NoError(t, err) + + require.Greater(t, migrationsCount, 1) + }) + + // with no migrate, gorp_migrations still exists: + test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: false}, func(db *sql.DB) { + // check that "gorp_migrations" migration tracking table still exists (as we have not set ApplyMigrations to true!) + var migrationsTableName string + if err := db.QueryRowContext(t.Context(), "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'gorp_migrations';").Scan(&migrationsTableName); err != nil { + t.Fatal(err) + } + + require.Equal(t, "gorp_migrations", migrationsTableName) + }) +} + +func TestWithTestDatabaseEmpty(t *testing.T) { + test.WithTestDatabaseEmpty(t, func(db1 *sql.DB) { + test.WithTestDatabaseEmpty(t, func(db2 *sql.DB) { + var db1Name string + err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name) + if err != nil { + t.Fatal(err) + } + + var db2Name string + err = db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name) + if err != nil { + t.Fatal(err) + } + require.NotEqual(t, db1Name, db2Name) + + // test apply migrations + fixtures to a empty database 1 + _, err = test.ApplyMigrations(t, db1) + require.NoError(t, err) + + _, err = test.ApplyTestFixtures(t.Context(), t, db1) + require.NoError(t, err) + + // test apply dump to a empty database 2 + dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql") + err = test.ApplyDump(t.Context(), t, db2, dumpFile) + require.NoError(t, err) + + // check user count + var usrCount int + if err := db1.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&usrCount); err != nil { + t.Fatal(err) + } + + require.Equal(t, 4, usrCount) + }) + }) +} diff --git a/internal/test/test_mailer.go b/internal/test/test_mailer.go new file mode 100644 index 0000000..2c2bd1b --- /dev/null +++ b/internal/test/test_mailer.go @@ -0,0 +1,66 @@ +package test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/mailer" + "allaboutapps.dev/aw/go-starter/internal/mailer/transport" + "github.com/jordan-wright/email" +) + +const ( + TestMailerDefaultSender = "test@example.com" +) + +func NewTestMailer(t *testing.T) *mailer.Mailer { + t.Helper() + + return newMailerWithTransporter(t, transport.NewMock()) +} + +func NewSMTPMailerFromDefaultEnv(t *testing.T) *mailer.Mailer { + t.Helper() + + config := config.DefaultServiceConfigFromEnv().SMTP + return newMailerWithTransporter(t, transport.NewSMTP(config)) +} + +func GetTestMailerMockTransport(t *testing.T, m *mailer.Mailer) *transport.MockMailTransport { + t.Helper() + mt, ok := m.Transport.(*transport.MockMailTransport) + if !ok { + t.Fatalf("invalid mailer transport type, got %T, want *transport.MockMailTransport", m.Transport) + } + + return mt +} + +func newMailerWithTransporter(t *testing.T, transporter transport.MailTransporter) *mailer.Mailer { + t.Helper() + + config := config.DefaultServiceConfigFromEnv().Mailer + config.DefaultSender = TestMailerDefaultSender + + mailer := mailer.New(config, transporter) + + if err := mailer.ParseTemplates(); err != nil { + t.Fatal("Failed to parse mailer templates", err) + } + + return mailer +} + +func GetLastSentMail(t *testing.T, m *mailer.Mailer) *email.Email { + t.Helper() + + mt := GetTestMailerMockTransport(t, m) + return mt.GetLastSentMail() +} + +func GetSentMails(t *testing.T, m *mailer.Mailer) []*email.Email { + t.Helper() + + mt := GetTestMailerMockTransport(t, m) + return mt.GetSentMails() +} diff --git a/internal/test/test_mailer_test.go b/internal/test/test_mailer_test.go new file mode 100644 index 0000000..424812c --- /dev/null +++ b/internal/test/test_mailer_test.go @@ -0,0 +1,67 @@ +package test_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/mailer/transport" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithTestMailer(t *testing.T) { + ctx := t.Context() + fix := fixtures.Fixtures() + //nolint:gosec + passwordResetLink := "http://localhost/password/reset/12345" + + mailer1 := test.NewTestMailer(t) + mailer2 := test.NewTestMailer(t) + err := mailer1.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink) + require.NoError(t, err) + + sender2 := "test2@example.com" + mailer2.Config.DefaultSender = sender2 + err = mailer2.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink) + require.NoError(t, err) + + mt1 := test.GetTestMailerMockTransport(t, mailer1) + mail := mt1.GetLastSentMail() + mails := mt1.GetSentMails() + + assert.Equal(t, mail, test.GetLastSentMail(t, mailer1)) + assert.Equal(t, mails, test.GetSentMails(t, mailer1)) + + require.NotNil(t, mail) + require.Len(t, mails, 1) + assert.Equal(t, mailer1.Config.DefaultSender, mail.From) + assert.Len(t, mail.To, 1) + assert.Equal(t, fix.User1.Username.String, mail.To[0]) + assert.Equal(t, test.TestMailerDefaultSender, mail.From) + assert.Equal(t, "Password reset", mail.Subject) + assert.Contains(t, string(mail.HTML), passwordResetLink) + + mt2 := test.GetTestMailerMockTransport(t, mailer2) + mail = mt2.GetLastSentMail() + mails = mt2.GetSentMails() + + assert.Equal(t, mail, test.GetLastSentMail(t, mailer2)) + assert.Equal(t, mails, test.GetSentMails(t, mailer2)) + + require.NotNil(t, mail) + require.Len(t, mails, 1) + assert.Equal(t, mailer2.Config.DefaultSender, mail.From) + assert.Len(t, mail.To, 1) + assert.Equal(t, fix.User1.Username.String, mail.To[0]) + assert.Equal(t, sender2, mail.From) + assert.Equal(t, "Password reset", mail.Subject) + assert.Contains(t, string(mail.HTML), passwordResetLink) +} + +func TestWithSMTPMailerFromDefaultEnv(t *testing.T) { + m := test.NewSMTPMailerFromDefaultEnv(t) + require.NotNil(t, m) + require.NotEmpty(t, m.Transport) + assert.IsType(t, &transport.SMTPMailTransport{}, m.Transport) +} diff --git a/internal/test/test_pusher.go b/internal/test/test_pusher.go new file mode 100644 index 0000000..956e512 --- /dev/null +++ b/internal/test/test_pusher.go @@ -0,0 +1,28 @@ +package test + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/push" + "allaboutapps.dev/aw/go-starter/internal/push/provider" +) + +func WithTestPusher(t *testing.T, closure func(p *push.Service, db *sql.DB)) { + t.Helper() + + WithTestDatabase(t, func(db *sql.DB) { + t.Helper() + closure(NewTestPusher(t, db), db) + }) +} + +func NewTestPusher(t *testing.T, db *sql.DB) *push.Service { + t.Helper() + + pushService := push.New(db) + mockProvider := provider.NewMock(push.ProviderTypeFCM) + pushService.RegisterProvider(mockProvider) + + return pushService +} diff --git a/internal/test/test_server.go b/internal/test/test_server.go new file mode 100644 index 0000000..4b84263 --- /dev/null +++ b/internal/test/test_server.go @@ -0,0 +1,93 @@ +package test + +import ( + "context" + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/router" + "allaboutapps.dev/aw/go-starter/internal/config" +) + +// WithTestServer returns a fully configured server (using the default server config). +func WithTestServer(t *testing.T, closure func(s *api.Server)) { + t.Helper() + defaultConfig := config.DefaultServiceConfigFromEnv() + WithTestServerConfigurable(t, defaultConfig, closure) +} + +// WithTestServerFromDump returns a fully configured server (using the default server config) and allows for a database dump to be injected. +func WithTestServerFromDump(t *testing.T, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) { + t.Helper() + defaultConfig := config.DefaultServiceConfigFromEnv() + WithTestServerConfigurableFromDump(t, defaultConfig, dumpConfig, closure) +} + +// WithTestServerConfigurable returns a fully configured server, allowing for configuration using the provided server config. +func WithTestServerConfigurable(t *testing.T, config config.Server, closure func(s *api.Server)) { + t.Helper() + ctx := t.Context() + WithTestServerConfigurableContext(ctx, t, config, closure) +} + +// WithTestServerConfigurableContext returns a fully configured server, allowing for configuration using the provided server config. +// The provided context will be used during setup (instead of the default background context). +func WithTestServerConfigurableContext(ctx context.Context, t *testing.T, config config.Server, closure func(s *api.Server)) { + t.Helper() + WithTestDatabaseContext(ctx, t, func(db *sql.DB) { + t.Helper() + execClosureNewTestServer(ctx, t, config, db, closure) + }) +} + +// WithTestServerConfigurableFromDump returns a fully configured server, allowing for configuration using the provided server config and a database dump to be injected. +func WithTestServerConfigurableFromDump(t *testing.T, config config.Server, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) { + t.Helper() + ctx := t.Context() + WithTestServerConfigurableFromDumpContext(ctx, t, config, dumpConfig, closure) +} + +// WithTestServerConfigurableFromDumpContext returns a fully configured server, allowing for configuration using the provided server config and a database dump to be injected. +// The provided context will be used during setup (instead of the default background context). +func WithTestServerConfigurableFromDumpContext(ctx context.Context, t *testing.T, config config.Server, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) { + t.Helper() + WithTestDatabaseFromDump(t, dumpConfig, func(db *sql.DB) { + t.Helper() + execClosureNewTestServer(ctx, t, config, db, closure) + }) +} + +// Executes closure on a new test server with a pre-provided database +func execClosureNewTestServer(ctx context.Context, t *testing.T, config config.Server, db *sql.DB, closure func(s *api.Server)) { + t.Helper() + + // https://stackoverflow.com/questions/43424787/how-to-use-next-available-port-in-http-listenandserve + // You may use port 0 to indicate you're not specifying an exact port but you want a free, available port selected by the system + config.Echo.ListenAddress = ":0" + + // always use the mock pusher in tests + config.Push.UseFCMProvider = false + config.Push.UseMockProvider = true + + s, err := api.InitNewServerWithDB(config, db, t) + if err != nil { + t.Fatalf("Failed to initialize server: %v", err) + } + + err = router.Init(s) + if err != nil { + t.Fatalf("Failed to init router: %v", err) + } + + closure(s) + + // echo is managed and should close automatically after running the test + if err := s.Echo.Shutdown(ctx); err != nil { + t.Fatalf("failed to shutdown server: %v", err) + } + + // disallow any further refs to managed object after running the test + //nolint: wastedassign + s = nil +} diff --git a/internal/test/test_server_test.go b/internal/test/test_server_test.go new file mode 100644 index 0000000..4741723 --- /dev/null +++ b/internal/test/test_server_test.go @@ -0,0 +1,151 @@ +package test_test + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type TestRequestPayload struct { + Name string `json:"name"` +} + +type TestResponsePayload struct { + Hello string `json:"hello"` +} + +// Validate validates this request payload +func (m *TestRequestPayload) Validate(_ strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *TestRequestPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + + res, err := swag.WriteJSON(m) + if err != nil { + return nil, fmt.Errorf("failed to marshal binary: %w", err) + } + + return res, nil +} + +// UnmarshalBinary interface implementation +func (m *TestRequestPayload) UnmarshalBinary(b []byte) error { + var res TestRequestPayload + if err := swag.ReadJSON(b, &res); err != nil { + return fmt.Errorf("failed to unmarshal binary: %w", err) + } + *m = res + return nil +} + +// Validate validates this response payload +func (m *TestResponsePayload) Validate(_ strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *TestResponsePayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + + res, err := swag.WriteJSON(m) + if err != nil { + return nil, fmt.Errorf("failed to marshal binary: %w", err) + } + + return res, nil +} + +// UnmarshalBinary interface implementation +func (m *TestResponsePayload) UnmarshalBinary(b []byte) error { + var res TestResponsePayload + if err := swag.ReadJSON(b, &res); err != nil { + return fmt.Errorf("failed to unmarshal binary: %w", err) + } + *m = res + return nil +} + +func TestWithTestServer(t *testing.T) { + test.WithTestServer(t, func(server1 *api.Server) { + test.WithTestServer(t, func(server2 *api.Server) { + path := "/testing-f679dbac-62bb-445d-b7e8-9f2c71ca382c" + + // add an new route to s1 for the purpose of this test. + server1.Echo.POST(path, func(c echo.Context) error { + var body TestRequestPayload + if err := util.BindAndValidateBody(c, &body); err != nil { + t.Fatal(err) + } + + response := TestResponsePayload{ + Hello: body.Name, + } + + return util.ValidateAndReturn(c, http.StatusOK, &response) + }) + + payload := test.GenericPayload{ + "name": "Mario", + } + + res1 := test.PerformRequest(t, server1, "POST", path, payload, nil) + assert.Equal(t, http.StatusOK, res1.Result().StatusCode) + + var response1 TestResponsePayload + test.ParseResponseAndValidate(t, res1, &response1) + + assert.Equal(t, "Mario", response1.Hello) + + res2 := test.PerformRequest(t, server2, "POST", path, payload, nil) + assert.Equal(t, http.StatusNotFound, res2.Result().StatusCode) + }) + }) +} + +func TestWithTestServerFromDump(t *testing.T) { + dumpFile := filepath.Join(util.GetProjectRootDir(), "/test/testdata/plain.sql") + + serverConfig := config.DefaultServiceConfigFromEnv() + dumpConfig := test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true, ApplyTestFixtures: true} + + test.WithTestServerFromDump(t, dumpConfig, func(server1 *api.Server) { + test.WithTestServerConfigurableFromDump(t, serverConfig, dumpConfig, func(server2 *api.Server) { + var db1Name string + if err := server1.DB.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil { + t.Fatal(err) + } + + var db2Name string + if err := server2.DB.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil { + t.Fatal(err) + } + + require.NotEqual(t, db1Name, db2Name) + + // same dumpConfig settings - must be same base template hash. + db1Hash := strings.Split(strings.Join(strings.Split(db1Name, "integresql_test_"), ""), "_")[0] + db2Hash := strings.Split(strings.Join(strings.Split(db2Name, "integresql_test_"), ""), "_")[0] + + require.Equal(t, db1Hash, db2Hash) + }) + }) +} diff --git a/internal/test/testdata/.env.test.local b/internal/test/testdata/.env.test.local new file mode 100644 index 0000000..f9f1ada --- /dev/null +++ b/internal/test/testdata/.env.test.local @@ -0,0 +1,2 @@ +# for a simple overwrite test via TestDotEnvLoadFileOrSkipTest +SERVER_FRONTEND_BASE_URL=http://overwritten.dotenv.frontend.url.tld:3000 \ No newline at end of file diff --git a/internal/test/testdata/TestSnapshotWithLocation.golden b/internal/test/testdata/TestSnapshotWithLocation.golden new file mode 100644 index 0000000..66fe1a0 --- /dev/null +++ b/internal/test/testdata/TestSnapshotWithLocation.golden @@ -0,0 +1,6 @@ +(struct { A string; B int; C bool; D *string }) { + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} diff --git a/internal/test/testing_mock.go b/internal/test/testing_mock.go new file mode 100644 index 0000000..82d91f2 --- /dev/null +++ b/internal/test/testing_mock.go @@ -0,0 +1,31 @@ +package test + +import "testing" + +// TestingT is used to generate a mock of testing.T to enable testing +// of helper methods which are using assert/require +// Inspired by: https://github.com/uber-go/zap/blob/master/zaptest/testingt_test.go, commit 5b0fd114dcc089875ee61dfad3617c3a43c2e93e +// +//nolint:interfacebloat +type TestingT interface { + Cleanup(f func()) + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + Fail() + FailNow() + Failed() bool + Fatal(args ...interface{}) + Fatalf(format string, args ...interface{}) + Helper() + Log(args ...interface{}) + Logf(format string, args ...interface{}) + Name() string + Skip(args ...interface{}) + SkipNow() + Skipf(format string, args ...interface{}) + Skipped() bool + TempDir() string +} + +// used to ensure compatibility between this interface and testing.TB +var _ TestingT = (testing.TB)(nil) diff --git a/internal/types/auth/delete_user_account_route_parameters.go b/internal/types/auth/delete_user_account_route_parameters.go new file mode 100644 index 0000000..7049683 --- /dev/null +++ b/internal/types/auth/delete_user_account_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewDeleteUserAccountRouteParams creates a new DeleteUserAccountRouteParams object +// no default values defined in spec. +func NewDeleteUserAccountRouteParams() DeleteUserAccountRouteParams { + + return DeleteUserAccountRouteParams{} +} + +// DeleteUserAccountRouteParams contains all the bound params for the delete user account route operation +// typically these are obtained from a http.Request +// +// swagger:parameters DeleteUserAccountRoute +type DeleteUserAccountRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.DeleteUserAccountPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewDeleteUserAccountRouteParams() beforehand. +func (o *DeleteUserAccountRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.DeleteUserAccountPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *DeleteUserAccountRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/get_complete_register_route_parameters.go b/internal/types/auth/get_complete_register_route_parameters.go new file mode 100644 index 0000000..8a3c191 --- /dev/null +++ b/internal/types/auth/get_complete_register_route_parameters.go @@ -0,0 +1,120 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" +) + +// NewGetCompleteRegisterRouteParams creates a new GetCompleteRegisterRouteParams object +// no default values defined in spec. +func NewGetCompleteRegisterRouteParams() GetCompleteRegisterRouteParams { + + return GetCompleteRegisterRouteParams{} +} + +// GetCompleteRegisterRouteParams contains all the bound params for the get complete register route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetCompleteRegisterRoute +type GetCompleteRegisterRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /*Registration token to complete the registration process + Required: true + In: query + */ + Token strfmt.UUID4 `query:"token"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetCompleteRegisterRouteParams() beforehand. +func (o *GetCompleteRegisterRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + qs := runtime.Values(r.URL.Query()) + + qToken, qhkToken, _ := qs.GetOK("token") + if err := o.bindToken(qToken, qhkToken, route.Formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetCompleteRegisterRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // token + // Required: true + // AllowEmptyValue: false + if err := validate.Required("token", "query", o.Token); err != nil { + res = append(res, err) + } + + if err := o.validateToken(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// bindToken binds and validates parameter Token from query. +func (o *GetCompleteRegisterRouteParams) bindToken(rawData []string, hasKey bool, formats strfmt.Registry) error { + if !hasKey { + return errors.Required("token", "query", rawData) + } + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: true + // AllowEmptyValue: false + if err := validate.RequiredString("token", "query", raw); err != nil { + return err + } + + // Format: uuid4 + value, err := formats.Parse("uuid4", raw) + if err != nil { + return errors.InvalidType("token", "query", "strfmt.UUID4", raw) + } + o.Token = *(value.(*strfmt.UUID4)) + + if err := o.validateToken(formats); err != nil { + return err + } + + return nil +} + +// validateToken carries on validations for parameter Token +func (o *GetCompleteRegisterRouteParams) validateToken(formats strfmt.Registry) error { + + if err := validate.FormatOf("token", "query", "uuid4", o.Token.String(), formats); err != nil { + return err + } + return nil +} diff --git a/internal/types/auth/get_user_info_route_parameters.go b/internal/types/auth/get_user_info_route_parameters.go new file mode 100644 index 0000000..34775ba --- /dev/null +++ b/internal/types/auth/get_user_info_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetUserInfoRouteParams creates a new GetUserInfoRouteParams object +// no default values defined in spec. +func NewGetUserInfoRouteParams() GetUserInfoRouteParams { + + return GetUserInfoRouteParams{} +} + +// GetUserInfoRouteParams contains all the bound params for the get user info route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetUserInfoRoute +type GetUserInfoRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetUserInfoRouteParams() beforehand. +func (o *GetUserInfoRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetUserInfoRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_change_password_route_parameters.go b/internal/types/auth/post_change_password_route_parameters.go new file mode 100644 index 0000000..922f39a --- /dev/null +++ b/internal/types/auth/post_change_password_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostChangePasswordRouteParams creates a new PostChangePasswordRouteParams object +// no default values defined in spec. +func NewPostChangePasswordRouteParams() PostChangePasswordRouteParams { + + return PostChangePasswordRouteParams{} +} + +// PostChangePasswordRouteParams contains all the bound params for the post change password route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostChangePasswordRoute +type PostChangePasswordRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostChangePasswordPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostChangePasswordRouteParams() beforehand. +func (o *PostChangePasswordRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostChangePasswordPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostChangePasswordRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_complete_register_route_parameters.go b/internal/types/auth/post_complete_register_route_parameters.go new file mode 100644 index 0000000..61fc259 --- /dev/null +++ b/internal/types/auth/post_complete_register_route_parameters.go @@ -0,0 +1,108 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" +) + +// NewPostCompleteRegisterRouteParams creates a new PostCompleteRegisterRouteParams object +// no default values defined in spec. +func NewPostCompleteRegisterRouteParams() PostCompleteRegisterRouteParams { + + return PostCompleteRegisterRouteParams{} +} + +// PostCompleteRegisterRouteParams contains all the bound params for the post complete register route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostCompleteRegisterRoute +type PostCompleteRegisterRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /*Registration token to complete the registration process + Required: true + In: path + */ + RegistrationToken strfmt.UUID4 `param:"registrationToken"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostCompleteRegisterRouteParams() beforehand. +func (o *PostCompleteRegisterRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + rRegistrationToken, rhkRegistrationToken, _ := route.Params.GetOK("registrationToken") + if err := o.bindRegistrationToken(rRegistrationToken, rhkRegistrationToken, route.Formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostCompleteRegisterRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // registrationToken + // Required: true + // Parameter is provided by construction from the route + + if err := o.validateRegistrationToken(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// bindRegistrationToken binds and validates parameter RegistrationToken from path. +func (o *PostCompleteRegisterRouteParams) bindRegistrationToken(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: true + // Parameter is provided by construction from the route + + // Format: uuid4 + value, err := formats.Parse("uuid4", raw) + if err != nil { + return errors.InvalidType("registrationToken", "path", "strfmt.UUID4", raw) + } + o.RegistrationToken = *(value.(*strfmt.UUID4)) + + if err := o.validateRegistrationToken(formats); err != nil { + return err + } + + return nil +} + +// validateRegistrationToken carries on validations for parameter RegistrationToken +func (o *PostCompleteRegisterRouteParams) validateRegistrationToken(formats strfmt.Registry) error { + + if err := validate.FormatOf("registrationToken", "path", "uuid4", o.RegistrationToken.String(), formats); err != nil { + return err + } + return nil +} diff --git a/internal/types/auth/post_forgot_password_complete_route_parameters.go b/internal/types/auth/post_forgot_password_complete_route_parameters.go new file mode 100644 index 0000000..1739579 --- /dev/null +++ b/internal/types/auth/post_forgot_password_complete_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostForgotPasswordCompleteRouteParams creates a new PostForgotPasswordCompleteRouteParams object +// no default values defined in spec. +func NewPostForgotPasswordCompleteRouteParams() PostForgotPasswordCompleteRouteParams { + + return PostForgotPasswordCompleteRouteParams{} +} + +// PostForgotPasswordCompleteRouteParams contains all the bound params for the post forgot password complete route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostForgotPasswordCompleteRoute +type PostForgotPasswordCompleteRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostForgotPasswordCompletePayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostForgotPasswordCompleteRouteParams() beforehand. +func (o *PostForgotPasswordCompleteRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostForgotPasswordCompletePayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostForgotPasswordCompleteRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_forgot_password_route_parameters.go b/internal/types/auth/post_forgot_password_route_parameters.go new file mode 100644 index 0000000..4427d3e --- /dev/null +++ b/internal/types/auth/post_forgot_password_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostForgotPasswordRouteParams creates a new PostForgotPasswordRouteParams object +// no default values defined in spec. +func NewPostForgotPasswordRouteParams() PostForgotPasswordRouteParams { + + return PostForgotPasswordRouteParams{} +} + +// PostForgotPasswordRouteParams contains all the bound params for the post forgot password route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostForgotPasswordRoute +type PostForgotPasswordRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostForgotPasswordPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostForgotPasswordRouteParams() beforehand. +func (o *PostForgotPasswordRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostForgotPasswordPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostForgotPasswordRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_login_route_parameters.go b/internal/types/auth/post_login_route_parameters.go new file mode 100644 index 0000000..56fb218 --- /dev/null +++ b/internal/types/auth/post_login_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostLoginRouteParams creates a new PostLoginRouteParams object +// no default values defined in spec. +func NewPostLoginRouteParams() PostLoginRouteParams { + + return PostLoginRouteParams{} +} + +// PostLoginRouteParams contains all the bound params for the post login route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostLoginRoute +type PostLoginRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostLoginPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostLoginRouteParams() beforehand. +func (o *PostLoginRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostLoginPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostLoginRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_logout_route_parameters.go b/internal/types/auth/post_logout_route_parameters.go new file mode 100644 index 0000000..71ff384 --- /dev/null +++ b/internal/types/auth/post_logout_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostLogoutRouteParams creates a new PostLogoutRouteParams object +// no default values defined in spec. +func NewPostLogoutRouteParams() PostLogoutRouteParams { + + return PostLogoutRouteParams{} +} + +// PostLogoutRouteParams contains all the bound params for the post logout route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostLogoutRoute +type PostLogoutRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostLogoutPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostLogoutRouteParams() beforehand. +func (o *PostLogoutRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostLogoutPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostLogoutRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_refresh_route_parameters.go b/internal/types/auth/post_refresh_route_parameters.go new file mode 100644 index 0000000..721904b --- /dev/null +++ b/internal/types/auth/post_refresh_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostRefreshRouteParams creates a new PostRefreshRouteParams object +// no default values defined in spec. +func NewPostRefreshRouteParams() PostRefreshRouteParams { + + return PostRefreshRouteParams{} +} + +// PostRefreshRouteParams contains all the bound params for the post refresh route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostRefreshRoute +type PostRefreshRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostRefreshPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostRefreshRouteParams() beforehand. +func (o *PostRefreshRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostRefreshPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostRefreshRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/auth/post_register_route_parameters.go b/internal/types/auth/post_register_route_parameters.go new file mode 100644 index 0000000..1d32910 --- /dev/null +++ b/internal/types/auth/post_register_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package auth + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPostRegisterRouteParams creates a new PostRegisterRouteParams object +// no default values defined in spec. +func NewPostRegisterRouteParams() PostRegisterRouteParams { + + return PostRegisterRouteParams{} +} + +// PostRegisterRouteParams contains all the bound params for the post register route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PostRegisterRoute +type PostRegisterRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PostRegisterPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPostRegisterRouteParams() beforehand. +func (o *PostRegisterRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PostRegisterPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PostRegisterRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/common/get_healthy_route_parameters.go b/internal/types/common/get_healthy_route_parameters.go new file mode 100644 index 0000000..868e6bd --- /dev/null +++ b/internal/types/common/get_healthy_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package common + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetHealthyRouteParams creates a new GetHealthyRouteParams object +// no default values defined in spec. +func NewGetHealthyRouteParams() GetHealthyRouteParams { + + return GetHealthyRouteParams{} +} + +// GetHealthyRouteParams contains all the bound params for the get healthy route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetHealthyRoute +type GetHealthyRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetHealthyRouteParams() beforehand. +func (o *GetHealthyRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetHealthyRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/common/get_ready_route_parameters.go b/internal/types/common/get_ready_route_parameters.go new file mode 100644 index 0000000..a11e8a0 --- /dev/null +++ b/internal/types/common/get_ready_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package common + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetReadyRouteParams creates a new GetReadyRouteParams object +// no default values defined in spec. +func NewGetReadyRouteParams() GetReadyRouteParams { + + return GetReadyRouteParams{} +} + +// GetReadyRouteParams contains all the bound params for the get ready route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetReadyRoute +type GetReadyRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetReadyRouteParams() beforehand. +func (o *GetReadyRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetReadyRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/common/get_swagger_route_parameters.go b/internal/types/common/get_swagger_route_parameters.go new file mode 100644 index 0000000..dc4a5bf --- /dev/null +++ b/internal/types/common/get_swagger_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package common + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetSwaggerRouteParams creates a new GetSwaggerRouteParams object +// no default values defined in spec. +func NewGetSwaggerRouteParams() GetSwaggerRouteParams { + + return GetSwaggerRouteParams{} +} + +// GetSwaggerRouteParams contains all the bound params for the get swagger route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetSwaggerRoute +type GetSwaggerRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetSwaggerRouteParams() beforehand. +func (o *GetSwaggerRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetSwaggerRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/common/get_version_route_parameters.go b/internal/types/common/get_version_route_parameters.go new file mode 100644 index 0000000..ddf101a --- /dev/null +++ b/internal/types/common/get_version_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package common + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetVersionRouteParams creates a new GetVersionRouteParams object +// no default values defined in spec. +func NewGetVersionRouteParams() GetVersionRouteParams { + + return GetVersionRouteParams{} +} + +// GetVersionRouteParams contains all the bound params for the get version route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetVersionRoute +type GetVersionRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetVersionRouteParams() beforehand. +func (o *GetVersionRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetVersionRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/delete_user_account_payload.go b/internal/types/delete_user_account_payload.go new file mode 100644 index 0000000..0016f55 --- /dev/null +++ b/internal/types/delete_user_account_payload.go @@ -0,0 +1,82 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// DeleteUserAccountPayload delete user account payload +// +// swagger:model deleteUserAccountPayload +type DeleteUserAccountPayload struct { + + // Current password of user + // Example: correct horse battery staple + // Required: true + // Max Length: 500 + // Min Length: 1 + CurrentPassword *string `json:"currentPassword"` +} + +// Validate validates this delete user account payload +func (m *DeleteUserAccountPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateCurrentPassword(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *DeleteUserAccountPayload) validateCurrentPassword(formats strfmt.Registry) error { + + if err := validate.Required("currentPassword", "body", m.CurrentPassword); err != nil { + return err + } + + if err := validate.MinLength("currentPassword", "body", *m.CurrentPassword, 1); err != nil { + return err + } + + if err := validate.MaxLength("currentPassword", "body", *m.CurrentPassword, 500); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this delete user account payload based on context it is used +func (m *DeleteUserAccountPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *DeleteUserAccountPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *DeleteUserAccountPayload) UnmarshalBinary(b []byte) error { + var res DeleteUserAccountPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/get_user_info_response.go b/internal/types/get_user_info_response.go new file mode 100644 index 0000000..9c4c4e8 --- /dev/null +++ b/internal/types/get_user_info_response.go @@ -0,0 +1,162 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// GetUserInfoResponse get user info response +// +// swagger:model getUserInfoResponse +type GetUserInfoResponse struct { + + // Email address of user, if available + // Example: user@example.com + // Max Length: 255 + // Format: email + Email strfmt.Email `json:"email,omitempty"` + + // Auth-Scopes of the user, if available + // Example: ["app"] + Scopes []string `json:"scopes"` + + // ID of user + // Example: 82ebdfad-c586-4407-a873-4cc1c33d56fc + // Required: true + Sub *string `json:"sub"` + + // Unix timestamp the user's info was last updated at + // Example: 1591960808 + // Required: true + UpdatedAt *int64 `json:"updated_at"` +} + +// Validate validates this get user info response +func (m *GetUserInfoResponse) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateEmail(formats); err != nil { + res = append(res, err) + } + + if err := m.validateScopes(formats); err != nil { + res = append(res, err) + } + + if err := m.validateSub(formats); err != nil { + res = append(res, err) + } + + if err := m.validateUpdatedAt(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *GetUserInfoResponse) validateEmail(formats strfmt.Registry) error { + if swag.IsZero(m.Email) { // not required + return nil + } + + if err := validate.MaxLength("email", "body", m.Email.String(), 255); err != nil { + return err + } + + if err := validate.FormatOf("email", "body", "email", m.Email.String(), formats); err != nil { + return err + } + + return nil +} + +var getUserInfoResponseScopesItemsEnum []interface{} + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["app","cms"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + getUserInfoResponseScopesItemsEnum = append(getUserInfoResponseScopesItemsEnum, v) + } +} + +func (m *GetUserInfoResponse) validateScopesItemsEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, getUserInfoResponseScopesItemsEnum, true); err != nil { + return err + } + return nil +} + +func (m *GetUserInfoResponse) validateScopes(formats strfmt.Registry) error { + if swag.IsZero(m.Scopes) { // not required + return nil + } + + for i := 0; i < len(m.Scopes); i++ { + + // value enum + if err := m.validateScopesItemsEnum("scopes"+"."+strconv.Itoa(i), "body", m.Scopes[i]); err != nil { + return err + } + + } + + return nil +} + +func (m *GetUserInfoResponse) validateSub(formats strfmt.Registry) error { + + if err := validate.Required("sub", "body", m.Sub); err != nil { + return err + } + + return nil +} + +func (m *GetUserInfoResponse) validateUpdatedAt(formats strfmt.Registry) error { + + if err := validate.Required("updated_at", "body", m.UpdatedAt); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this get user info response based on context it is used +func (m *GetUserInfoResponse) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *GetUserInfoResponse) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *GetUserInfoResponse) UnmarshalBinary(b []byte) error { + var res GetUserInfoResponse + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/http_validation_error_detail.go b/internal/types/http_validation_error_detail.go new file mode 100644 index 0000000..0afbbbd --- /dev/null +++ b/internal/types/http_validation_error_detail.go @@ -0,0 +1,105 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// HTTPValidationErrorDetail http validation error detail +// +// swagger:model httpValidationErrorDetail +type HTTPValidationErrorDetail struct { + + // Error describing field validation failure + // Required: true + Error *string `json:"error"` + + // Indicates how the invalid field was provided + // Required: true + In *string `json:"in"` + + // Key of field failing validation + // Required: true + Key *string `json:"key"` +} + +// Validate validates this http validation error detail +func (m *HTTPValidationErrorDetail) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateError(formats); err != nil { + res = append(res, err) + } + + if err := m.validateIn(formats); err != nil { + res = append(res, err) + } + + if err := m.validateKey(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *HTTPValidationErrorDetail) validateError(formats strfmt.Registry) error { + + if err := validate.Required("error", "body", m.Error); err != nil { + return err + } + + return nil +} + +func (m *HTTPValidationErrorDetail) validateIn(formats strfmt.Registry) error { + + if err := validate.Required("in", "body", m.In); err != nil { + return err + } + + return nil +} + +func (m *HTTPValidationErrorDetail) validateKey(formats strfmt.Registry) error { + + if err := validate.Required("key", "body", m.Key); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this http validation error detail based on context it is used +func (m *HTTPValidationErrorDetail) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *HTTPValidationErrorDetail) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *HTTPValidationErrorDetail) UnmarshalBinary(b []byte) error { + var res HTTPValidationErrorDetail + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/order_dir.go b/internal/types/order_dir.go new file mode 100644 index 0000000..6bb8dda --- /dev/null +++ b/internal/types/order_dir.go @@ -0,0 +1,78 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "encoding/json" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" +) + +// OrderDir order dir +// +// swagger:model orderDir +type OrderDir string + +func NewOrderDir(value OrderDir) *OrderDir { + return &value +} + +// Pointer returns a pointer to a freshly-allocated OrderDir. +func (m OrderDir) Pointer() *OrderDir { + return &m +} + +const ( + + // OrderDirAsc captures enum value "asc" + OrderDirAsc OrderDir = "asc" + + // OrderDirDesc captures enum value "desc" + OrderDirDesc OrderDir = "desc" +) + +// for schema +var orderDirEnum []interface{} + +func init() { + var res []OrderDir + if err := json.Unmarshal([]byte(`["asc","desc"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + orderDirEnum = append(orderDirEnum, v) + } +} + +func (m OrderDir) validateOrderDirEnum(path, location string, value OrderDir) error { + if err := validate.EnumCase(path, location, value, orderDirEnum, true); err != nil { + return err + } + return nil +} + +// Validate validates this order dir +func (m OrderDir) Validate(formats strfmt.Registry) error { + var res []error + + // value enum + if err := m.validateOrderDirEnum("", "body", m); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// ContextValidate validates this order dir based on context it is used +func (m OrderDir) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} diff --git a/internal/types/post_change_password_payload.go b/internal/types/post_change_password_payload.go new file mode 100644 index 0000000..da407ba --- /dev/null +++ b/internal/types/post_change_password_payload.go @@ -0,0 +1,110 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostChangePasswordPayload post change password payload +// +// swagger:model postChangePasswordPayload +type PostChangePasswordPayload struct { + + // Current password of user + // Example: correct horse battery staple + // Required: true + // Max Length: 500 + // Min Length: 1 + CurrentPassword *string `json:"currentPassword"` + + // New password to set for user + // Example: correct horse battery staple + // Required: true + // Max Length: 500 + // Min Length: 1 + NewPassword *string `json:"newPassword"` +} + +// Validate validates this post change password payload +func (m *PostChangePasswordPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateCurrentPassword(formats); err != nil { + res = append(res, err) + } + + if err := m.validateNewPassword(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostChangePasswordPayload) validateCurrentPassword(formats strfmt.Registry) error { + + if err := validate.Required("currentPassword", "body", m.CurrentPassword); err != nil { + return err + } + + if err := validate.MinLength("currentPassword", "body", *m.CurrentPassword, 1); err != nil { + return err + } + + if err := validate.MaxLength("currentPassword", "body", *m.CurrentPassword, 500); err != nil { + return err + } + + return nil +} + +func (m *PostChangePasswordPayload) validateNewPassword(formats strfmt.Registry) error { + + if err := validate.Required("newPassword", "body", m.NewPassword); err != nil { + return err + } + + if err := validate.MinLength("newPassword", "body", *m.NewPassword, 1); err != nil { + return err + } + + if err := validate.MaxLength("newPassword", "body", *m.NewPassword, 500); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post change password payload based on context it is used +func (m *PostChangePasswordPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostChangePasswordPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostChangePasswordPayload) UnmarshalBinary(b []byte) error { + var res PostChangePasswordPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_forgot_password_complete_payload.go b/internal/types/post_forgot_password_complete_payload.go new file mode 100644 index 0000000..4ff2085 --- /dev/null +++ b/internal/types/post_forgot_password_complete_payload.go @@ -0,0 +1,105 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostForgotPasswordCompletePayload post forgot password complete payload +// +// swagger:model postForgotPasswordCompletePayload +type PostForgotPasswordCompletePayload struct { + + // New password to set for user + // Example: correct horse battery staple + // Required: true + // Max Length: 500 + // Min Length: 1 + Password *string `json:"password"` + + // Password reset token sent via email + // Example: ec16f032-3c44-4148-bbcc-45557466fa74 + // Required: true + // Format: uuid4 + Token *strfmt.UUID4 `json:"token"` +} + +// Validate validates this post forgot password complete payload +func (m *PostForgotPasswordCompletePayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validatePassword(formats); err != nil { + res = append(res, err) + } + + if err := m.validateToken(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostForgotPasswordCompletePayload) validatePassword(formats strfmt.Registry) error { + + if err := validate.Required("password", "body", m.Password); err != nil { + return err + } + + if err := validate.MinLength("password", "body", *m.Password, 1); err != nil { + return err + } + + if err := validate.MaxLength("password", "body", *m.Password, 500); err != nil { + return err + } + + return nil +} + +func (m *PostForgotPasswordCompletePayload) validateToken(formats strfmt.Registry) error { + + if err := validate.Required("token", "body", m.Token); err != nil { + return err + } + + if err := validate.FormatOf("token", "body", "uuid4", m.Token.String(), formats); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post forgot password complete payload based on context it is used +func (m *PostForgotPasswordCompletePayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostForgotPasswordCompletePayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostForgotPasswordCompletePayload) UnmarshalBinary(b []byte) error { + var res PostForgotPasswordCompletePayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_forgot_password_payload.go b/internal/types/post_forgot_password_payload.go new file mode 100644 index 0000000..6c04d43 --- /dev/null +++ b/internal/types/post_forgot_password_payload.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostForgotPasswordPayload post forgot password payload +// +// swagger:model postForgotPasswordPayload +type PostForgotPasswordPayload struct { + + // Username to initiate password reset for + // Example: user@example.com + // Required: true + // Max Length: 255 + // Min Length: 1 + // Format: email + Username *strfmt.Email `json:"username"` +} + +// Validate validates this post forgot password payload +func (m *PostForgotPasswordPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateUsername(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostForgotPasswordPayload) validateUsername(formats strfmt.Registry) error { + + if err := validate.Required("username", "body", m.Username); err != nil { + return err + } + + if err := validate.MinLength("username", "body", m.Username.String(), 1); err != nil { + return err + } + + if err := validate.MaxLength("username", "body", m.Username.String(), 255); err != nil { + return err + } + + if err := validate.FormatOf("username", "body", "email", m.Username.String(), formats); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post forgot password payload based on context it is used +func (m *PostForgotPasswordPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostForgotPasswordPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostForgotPasswordPayload) UnmarshalBinary(b []byte) error { + var res PostForgotPasswordPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_login_payload.go b/internal/types/post_login_payload.go new file mode 100644 index 0000000..1be5c6c --- /dev/null +++ b/internal/types/post_login_payload.go @@ -0,0 +1,115 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostLoginPayload post login payload +// +// swagger:model postLoginPayload +type PostLoginPayload struct { + + // Password of user to authenticate as + // Example: correct horse battery staple + // Required: true + // Max Length: 500 + // Min Length: 1 + Password *string `json:"password"` + + // Username of user to authenticate as + // Example: user@example.com + // Required: true + // Max Length: 255 + // Min Length: 1 + // Format: email + Username *strfmt.Email `json:"username"` +} + +// Validate validates this post login payload +func (m *PostLoginPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validatePassword(formats); err != nil { + res = append(res, err) + } + + if err := m.validateUsername(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostLoginPayload) validatePassword(formats strfmt.Registry) error { + + if err := validate.Required("password", "body", m.Password); err != nil { + return err + } + + if err := validate.MinLength("password", "body", *m.Password, 1); err != nil { + return err + } + + if err := validate.MaxLength("password", "body", *m.Password, 500); err != nil { + return err + } + + return nil +} + +func (m *PostLoginPayload) validateUsername(formats strfmt.Registry) error { + + if err := validate.Required("username", "body", m.Username); err != nil { + return err + } + + if err := validate.MinLength("username", "body", m.Username.String(), 1); err != nil { + return err + } + + if err := validate.MaxLength("username", "body", m.Username.String(), 255); err != nil { + return err + } + + if err := validate.FormatOf("username", "body", "email", m.Username.String(), formats); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post login payload based on context it is used +func (m *PostLoginPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostLoginPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostLoginPayload) UnmarshalBinary(b []byte) error { + var res PostLoginPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_login_response.go b/internal/types/post_login_response.go new file mode 100644 index 0000000..6e1dfe5 --- /dev/null +++ b/internal/types/post_login_response.go @@ -0,0 +1,136 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostLoginResponse post login response +// +// swagger:model postLoginResponse +type PostLoginResponse struct { + + // Access token required for accessing protected API endpoints + // Example: c1247d8d-0d65-41c4-bc86-ec041d2ac437 + // Required: true + // Format: uuid4 + AccessToken *strfmt.UUID4 `json:"access_token"` + + // Access token expiry in seconds + // Example: 86400 + // Required: true + ExpiresIn *int64 `json:"expires_in"` + + // Refresh token for refreshing the access token once it expires + // Example: 1dadb3bd-50d8-485d-83a3-6111392568f0 + // Required: true + // Format: uuid4 + RefreshToken *strfmt.UUID4 `json:"refresh_token"` + + // Type of access token, will always be `bearer` + // Example: bearer + // Required: true + TokenType *string `json:"token_type"` +} + +// Validate validates this post login response +func (m *PostLoginResponse) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateAccessToken(formats); err != nil { + res = append(res, err) + } + + if err := m.validateExpiresIn(formats); err != nil { + res = append(res, err) + } + + if err := m.validateRefreshToken(formats); err != nil { + res = append(res, err) + } + + if err := m.validateTokenType(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostLoginResponse) validateAccessToken(formats strfmt.Registry) error { + + if err := validate.Required("access_token", "body", m.AccessToken); err != nil { + return err + } + + if err := validate.FormatOf("access_token", "body", "uuid4", m.AccessToken.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *PostLoginResponse) validateExpiresIn(formats strfmt.Registry) error { + + if err := validate.Required("expires_in", "body", m.ExpiresIn); err != nil { + return err + } + + return nil +} + +func (m *PostLoginResponse) validateRefreshToken(formats strfmt.Registry) error { + + if err := validate.Required("refresh_token", "body", m.RefreshToken); err != nil { + return err + } + + if err := validate.FormatOf("refresh_token", "body", "uuid4", m.RefreshToken.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *PostLoginResponse) validateTokenType(formats strfmt.Registry) error { + + if err := validate.Required("token_type", "body", m.TokenType); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post login response based on context it is used +func (m *PostLoginResponse) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostLoginResponse) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostLoginResponse) UnmarshalBinary(b []byte) error { + var res PostLoginResponse + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_logout_payload.go b/internal/types/post_logout_payload.go new file mode 100644 index 0000000..4b66f80 --- /dev/null +++ b/internal/types/post_logout_payload.go @@ -0,0 +1,75 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostLogoutPayload post logout payload +// +// swagger:model postLogoutPayload +type PostLogoutPayload struct { + + // Optional refresh token to delete while logging out + // Example: 700ebed3-40f7-4211-bc83-a89b22b9875e + // Format: uuid4 + RefreshToken strfmt.UUID4 `json:"refresh_token,omitempty"` +} + +// Validate validates this post logout payload +func (m *PostLogoutPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateRefreshToken(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostLogoutPayload) validateRefreshToken(formats strfmt.Registry) error { + if swag.IsZero(m.RefreshToken) { // not required + return nil + } + + if err := validate.FormatOf("refresh_token", "body", "uuid4", m.RefreshToken.String(), formats); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post logout payload based on context it is used +func (m *PostLogoutPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostLogoutPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostLogoutPayload) UnmarshalBinary(b []byte) error { + var res PostLogoutPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_refresh_payload.go b/internal/types/post_refresh_payload.go new file mode 100644 index 0000000..3933542 --- /dev/null +++ b/internal/types/post_refresh_payload.go @@ -0,0 +1,77 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostRefreshPayload post refresh payload +// +// swagger:model postRefreshPayload +type PostRefreshPayload struct { + + // Refresh token to use for retrieving new token set + // Example: 7503cd8a-c921-4368-a32d-6c1d01d86da9 + // Required: true + // Format: uuid4 + RefreshToken *strfmt.UUID4 `json:"refresh_token"` +} + +// Validate validates this post refresh payload +func (m *PostRefreshPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateRefreshToken(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostRefreshPayload) validateRefreshToken(formats strfmt.Registry) error { + + if err := validate.Required("refresh_token", "body", m.RefreshToken); err != nil { + return err + } + + if err := validate.FormatOf("refresh_token", "body", "uuid4", m.RefreshToken.String(), formats); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post refresh payload based on context it is used +func (m *PostRefreshPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostRefreshPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostRefreshPayload) UnmarshalBinary(b []byte) error { + var res PostRefreshPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/post_register_payload.go b/internal/types/post_register_payload.go new file mode 100644 index 0000000..75f47b4 --- /dev/null +++ b/internal/types/post_register_payload.go @@ -0,0 +1,115 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PostRegisterPayload post register payload +// +// swagger:model postRegisterPayload +type PostRegisterPayload struct { + + // Password to register with + // Example: correct horse battery staple + // Required: true + // Max Length: 500 + // Min Length: 1 + Password *string `json:"password"` + + // Username to register with + // Example: user@example.com + // Required: true + // Max Length: 255 + // Min Length: 1 + // Format: email + Username *strfmt.Email `json:"username"` +} + +// Validate validates this post register payload +func (m *PostRegisterPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validatePassword(formats); err != nil { + res = append(res, err) + } + + if err := m.validateUsername(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PostRegisterPayload) validatePassword(formats strfmt.Registry) error { + + if err := validate.Required("password", "body", m.Password); err != nil { + return err + } + + if err := validate.MinLength("password", "body", *m.Password, 1); err != nil { + return err + } + + if err := validate.MaxLength("password", "body", *m.Password, 500); err != nil { + return err + } + + return nil +} + +func (m *PostRegisterPayload) validateUsername(formats strfmt.Registry) error { + + if err := validate.Required("username", "body", m.Username); err != nil { + return err + } + + if err := validate.MinLength("username", "body", m.Username.String(), 1); err != nil { + return err + } + + if err := validate.MaxLength("username", "body", m.Username.String(), 255); err != nil { + return err + } + + if err := validate.FormatOf("username", "body", "email", m.Username.String(), formats); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this post register payload based on context it is used +func (m *PostRegisterPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PostRegisterPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PostRegisterPayload) UnmarshalBinary(b []byte) error { + var res PostRegisterPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/public_http_error.go b/internal/types/public_http_error.go new file mode 100644 index 0000000..1a2d368 --- /dev/null +++ b/internal/types/public_http_error.go @@ -0,0 +1,161 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PublicHTTPError public Http error +// +// swagger:model publicHttpError +type PublicHTTPError struct { + + // HTTP status code returned for the error + // Example: 403 + // Required: true + // Maximum: 599 + // Minimum: 100 + Code *int64 `json:"status"` + + // More detailed, human-readable, optional explanation of the error + // Example: User is lacking permission to access this resource + Detail string `json:"detail,omitempty"` + + // Short, human-readable description of the error + // Example: Forbidden + // Required: true + Title *string `json:"title"` + + // type + // Required: true + Type *PublicHTTPErrorType `json:"type"` +} + +// Validate validates this public Http error +func (m *PublicHTTPError) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateCode(formats); err != nil { + res = append(res, err) + } + + if err := m.validateTitle(formats); err != nil { + res = append(res, err) + } + + if err := m.validateType(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PublicHTTPError) validateCode(formats strfmt.Registry) error { + + if err := validate.Required("status", "body", m.Code); err != nil { + return err + } + + if err := validate.MinimumInt("status", "body", *m.Code, 100, false); err != nil { + return err + } + + if err := validate.MaximumInt("status", "body", *m.Code, 599, false); err != nil { + return err + } + + return nil +} + +func (m *PublicHTTPError) validateTitle(formats strfmt.Registry) error { + + if err := validate.Required("title", "body", m.Title); err != nil { + return err + } + + return nil +} + +func (m *PublicHTTPError) validateType(formats strfmt.Registry) error { + + if err := validate.Required("type", "body", m.Type); err != nil { + return err + } + + if err := validate.Required("type", "body", m.Type); err != nil { + return err + } + + if m.Type != nil { + if err := m.Type.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("type") + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("type") + } + return err + } + } + + return nil +} + +// ContextValidate validate this public Http error based on the context it is used +func (m *PublicHTTPError) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := m.contextValidateType(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PublicHTTPError) contextValidateType(ctx context.Context, formats strfmt.Registry) error { + + if m.Type != nil { + if err := m.Type.ContextValidate(ctx, formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("type") + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("type") + } + return err + } + } + + return nil +} + +// MarshalBinary interface implementation +func (m *PublicHTTPError) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PublicHTTPError) UnmarshalBinary(b []byte) error { + var res PublicHTTPError + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/public_http_error_type.go b/internal/types/public_http_error_type.go new file mode 100644 index 0000000..4e58115 --- /dev/null +++ b/internal/types/public_http_error_type.go @@ -0,0 +1,111 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "encoding/json" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" +) + +// PublicHTTPErrorType Type of error returned, should be used for client-side error handling +// +// swagger:model publicHttpErrorType +type PublicHTTPErrorType string + +func NewPublicHTTPErrorType(value PublicHTTPErrorType) *PublicHTTPErrorType { + return &value +} + +// Pointer returns a pointer to a freshly-allocated PublicHTTPErrorType. +func (m PublicHTTPErrorType) Pointer() *PublicHTTPErrorType { + return &m +} + +const ( + + // PublicHTTPErrorTypeGeneric captures enum value "generic" + PublicHTTPErrorTypeGeneric PublicHTTPErrorType = "generic" + + // PublicHTTPErrorTypePUSHTOKENALREADYEXISTS captures enum value "PUSH_TOKEN_ALREADY_EXISTS" + PublicHTTPErrorTypePUSHTOKENALREADYEXISTS PublicHTTPErrorType = "PUSH_TOKEN_ALREADY_EXISTS" + + // PublicHTTPErrorTypeOLDPUSHTOKENNOTFOUND captures enum value "OLD_PUSH_TOKEN_NOT_FOUND" + PublicHTTPErrorTypeOLDPUSHTOKENNOTFOUND PublicHTTPErrorType = "OLD_PUSH_TOKEN_NOT_FOUND" + + // PublicHTTPErrorTypeZEROFILESIZE captures enum value "ZERO_FILE_SIZE" + PublicHTTPErrorTypeZEROFILESIZE PublicHTTPErrorType = "ZERO_FILE_SIZE" + + // PublicHTTPErrorTypeUSERDEACTIVATED captures enum value "USER_DEACTIVATED" + PublicHTTPErrorTypeUSERDEACTIVATED PublicHTTPErrorType = "USER_DEACTIVATED" + + // PublicHTTPErrorTypeINVALIDPASSWORD captures enum value "INVALID_PASSWORD" + PublicHTTPErrorTypeINVALIDPASSWORD PublicHTTPErrorType = "INVALID_PASSWORD" + + // PublicHTTPErrorTypeNOTLOCALUSER captures enum value "NOT_LOCAL_USER" + PublicHTTPErrorTypeNOTLOCALUSER PublicHTTPErrorType = "NOT_LOCAL_USER" + + // PublicHTTPErrorTypeTOKENNOTFOUND captures enum value "TOKEN_NOT_FOUND" + PublicHTTPErrorTypeTOKENNOTFOUND PublicHTTPErrorType = "TOKEN_NOT_FOUND" + + // PublicHTTPErrorTypeTOKENEXPIRED captures enum value "TOKEN_EXPIRED" + PublicHTTPErrorTypeTOKENEXPIRED PublicHTTPErrorType = "TOKEN_EXPIRED" + + // PublicHTTPErrorTypeUSERALREADYEXISTS captures enum value "USER_ALREADY_EXISTS" + PublicHTTPErrorTypeUSERALREADYEXISTS PublicHTTPErrorType = "USER_ALREADY_EXISTS" + + // PublicHTTPErrorTypeMALFORMEDTOKEN captures enum value "MALFORMED_TOKEN" + PublicHTTPErrorTypeMALFORMEDTOKEN PublicHTTPErrorType = "MALFORMED_TOKEN" + + // PublicHTTPErrorTypeLASTAUTHENTICATEDATEXCEEDED captures enum value "LAST_AUTHENTICATED_AT_EXCEEDED" + PublicHTTPErrorTypeLASTAUTHENTICATEDATEXCEEDED PublicHTTPErrorType = "LAST_AUTHENTICATED_AT_EXCEEDED" + + // PublicHTTPErrorTypeMISSINGSCOPES captures enum value "MISSING_SCOPES" + PublicHTTPErrorTypeMISSINGSCOPES PublicHTTPErrorType = "MISSING_SCOPES" +) + +// for schema +var publicHttpErrorTypeEnum []interface{} + +func init() { + var res []PublicHTTPErrorType + if err := json.Unmarshal([]byte(`["generic","PUSH_TOKEN_ALREADY_EXISTS","OLD_PUSH_TOKEN_NOT_FOUND","ZERO_FILE_SIZE","USER_DEACTIVATED","INVALID_PASSWORD","NOT_LOCAL_USER","TOKEN_NOT_FOUND","TOKEN_EXPIRED","USER_ALREADY_EXISTS","MALFORMED_TOKEN","LAST_AUTHENTICATED_AT_EXCEEDED","MISSING_SCOPES"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + publicHttpErrorTypeEnum = append(publicHttpErrorTypeEnum, v) + } +} + +func (m PublicHTTPErrorType) validatePublicHTTPErrorTypeEnum(path, location string, value PublicHTTPErrorType) error { + if err := validate.EnumCase(path, location, value, publicHttpErrorTypeEnum, true); err != nil { + return err + } + return nil +} + +// Validate validates this public Http error type +func (m PublicHTTPErrorType) Validate(formats strfmt.Registry) error { + var res []error + + // value enum + if err := m.validatePublicHTTPErrorTypeEnum("", "body", m); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// ContextValidate validates this public Http error type based on context it is used +func (m PublicHTTPErrorType) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} diff --git a/internal/types/public_http_validation_error.go b/internal/types/public_http_validation_error.go new file mode 100644 index 0000000..986e0a9 --- /dev/null +++ b/internal/types/public_http_validation_error.go @@ -0,0 +1,175 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PublicHTTPValidationError public Http validation error +// +// swagger:model publicHttpValidationError +type PublicHTTPValidationError struct { + PublicHTTPError + + // List of errors received while validating payload against schema + // Required: true + ValidationErrors []*HTTPValidationErrorDetail `json:"validationErrors"` +} + +// UnmarshalJSON unmarshals this object from a JSON structure +func (m *PublicHTTPValidationError) UnmarshalJSON(raw []byte) error { + // AO0 + var aO0 PublicHTTPError + if err := swag.ReadJSON(raw, &aO0); err != nil { + return err + } + m.PublicHTTPError = aO0 + + // now for regular properties + var propsPublicHTTPValidationError struct { + ValidationErrors []*HTTPValidationErrorDetail `json:"validationErrors"` + } + if err := swag.ReadJSON(raw, &propsPublicHTTPValidationError); err != nil { + return err + } + m.ValidationErrors = propsPublicHTTPValidationError.ValidationErrors + + return nil +} + +// MarshalJSON marshals this object to a JSON structure +func (m PublicHTTPValidationError) MarshalJSON() ([]byte, error) { + _parts := make([][]byte, 0, 1) + + aO0, err := swag.WriteJSON(m.PublicHTTPError) + if err != nil { + return nil, err + } + _parts = append(_parts, aO0) + + // now for regular properties + var propsPublicHTTPValidationError struct { + ValidationErrors []*HTTPValidationErrorDetail `json:"validationErrors"` + } + propsPublicHTTPValidationError.ValidationErrors = m.ValidationErrors + + jsonDataPropsPublicHTTPValidationError, errPublicHTTPValidationError := swag.WriteJSON(propsPublicHTTPValidationError) + if errPublicHTTPValidationError != nil { + return nil, errPublicHTTPValidationError + } + _parts = append(_parts, jsonDataPropsPublicHTTPValidationError) + return swag.ConcatJSON(_parts...), nil +} + +// Validate validates this public Http validation error +func (m *PublicHTTPValidationError) Validate(formats strfmt.Registry) error { + var res []error + + // validation for a type composition with PublicHTTPError + if err := m.PublicHTTPError.Validate(formats); err != nil { + res = append(res, err) + } + + if err := m.validateValidationErrors(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PublicHTTPValidationError) validateValidationErrors(formats strfmt.Registry) error { + + if err := validate.Required("validationErrors", "body", m.ValidationErrors); err != nil { + return err + } + + for i := 0; i < len(m.ValidationErrors); i++ { + if swag.IsZero(m.ValidationErrors[i]) { // not required + continue + } + + if m.ValidationErrors[i] != nil { + if err := m.ValidationErrors[i].Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("validationErrors" + "." + strconv.Itoa(i)) + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("validationErrors" + "." + strconv.Itoa(i)) + } + return err + } + } + + } + + return nil +} + +// ContextValidate validate this public Http validation error based on the context it is used +func (m *PublicHTTPValidationError) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + // validation for a type composition with PublicHTTPError + if err := m.PublicHTTPError.ContextValidate(ctx, formats); err != nil { + res = append(res, err) + } + + if err := m.contextValidateValidationErrors(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PublicHTTPValidationError) contextValidateValidationErrors(ctx context.Context, formats strfmt.Registry) error { + + for i := 0; i < len(m.ValidationErrors); i++ { + + if m.ValidationErrors[i] != nil { + if err := m.ValidationErrors[i].ContextValidate(ctx, formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("validationErrors" + "." + strconv.Itoa(i)) + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("validationErrors" + "." + strconv.Itoa(i)) + } + return err + } + } + + } + + return nil +} + +// MarshalBinary interface implementation +func (m *PublicHTTPValidationError) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PublicHTTPValidationError) UnmarshalBinary(b []byte) error { + var res PublicHTTPValidationError + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/push/put_update_push_token_route_parameters.go b/internal/types/push/put_update_push_token_route_parameters.go new file mode 100644 index 0000000..edcdae0 --- /dev/null +++ b/internal/types/push/put_update_push_token_route_parameters.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package push + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + + "allaboutapps.dev/aw/go-starter/internal/types" +) + +// NewPutUpdatePushTokenRouteParams creates a new PutUpdatePushTokenRouteParams object +// no default values defined in spec. +func NewPutUpdatePushTokenRouteParams() PutUpdatePushTokenRouteParams { + + return PutUpdatePushTokenRouteParams{} +} + +// PutUpdatePushTokenRouteParams contains all the bound params for the put update push token route operation +// typically these are obtained from a http.Request +// +// swagger:parameters PutUpdatePushTokenRoute +type PutUpdatePushTokenRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + In: body + */ + Payload *types.PutUpdatePushTokenPayload +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPutUpdatePushTokenRouteParams() beforehand. +func (o *PutUpdatePushTokenRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body types.PutUpdatePushTokenPayload + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("payload", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Payload = &body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *PutUpdatePushTokenRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + // Payload + // Required: false + + // body is validated in endpoint + //if err := o.Payload.Validate(formats); err != nil { + // res = append(res, err) + //} + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/put_update_push_token_payload.go b/internal/types/put_update_push_token_payload.go new file mode 100644 index 0000000..ecc84da --- /dev/null +++ b/internal/types/put_update_push_token_payload.go @@ -0,0 +1,121 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PutUpdatePushTokenPayload put update push token payload +// +// swagger:model putUpdatePushTokenPayload +type PutUpdatePushTokenPayload struct { + + // New push token for given provider. + // Example: 1c91e550-8167-439c-8021-dee7de2f7e96 + // Required: true + // Max Length: 500 + NewToken *string `json:"newToken"` + + // Old token that can be deleted if present. + // Example: 495179de-b771-48f0-aab2-8d23701b0f02 + // Max Length: 500 + OldToken *string `json:"oldToken,omitempty"` + + // Identifier of the provider the token is for (eg. "fcm", "apn"). Currently only "fcm" is supported. + // Example: fcm + // Required: true + // Max Length: 500 + Provider *string `json:"provider"` +} + +// Validate validates this put update push token payload +func (m *PutUpdatePushTokenPayload) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateNewToken(formats); err != nil { + res = append(res, err) + } + + if err := m.validateOldToken(formats); err != nil { + res = append(res, err) + } + + if err := m.validateProvider(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PutUpdatePushTokenPayload) validateNewToken(formats strfmt.Registry) error { + + if err := validate.Required("newToken", "body", m.NewToken); err != nil { + return err + } + + if err := validate.MaxLength("newToken", "body", *m.NewToken, 500); err != nil { + return err + } + + return nil +} + +func (m *PutUpdatePushTokenPayload) validateOldToken(formats strfmt.Registry) error { + if swag.IsZero(m.OldToken) { // not required + return nil + } + + if err := validate.MaxLength("oldToken", "body", *m.OldToken, 500); err != nil { + return err + } + + return nil +} + +func (m *PutUpdatePushTokenPayload) validateProvider(formats strfmt.Registry) error { + + if err := validate.Required("provider", "body", m.Provider); err != nil { + return err + } + + if err := validate.MaxLength("provider", "body", *m.Provider, 500); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this put update push token payload based on context it is used +func (m *PutUpdatePushTokenPayload) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PutUpdatePushTokenPayload) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PutUpdatePushTokenPayload) UnmarshalBinary(b []byte) error { + var res PutUpdatePushTokenPayload + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/register_response.go b/internal/types/register_response.go new file mode 100644 index 0000000..e586a28 --- /dev/null +++ b/internal/types/register_response.go @@ -0,0 +1,72 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// RegisterResponse register response +// +// swagger:model registerResponse +type RegisterResponse struct { + + // Indicates whether the registration process requires email confirmation + // Example: true + // Required: true + RequiresConfirmation *bool `json:"requiresConfirmation"` +} + +// Validate validates this register response +func (m *RegisterResponse) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateRequiresConfirmation(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *RegisterResponse) validateRequiresConfirmation(formats strfmt.Registry) error { + + if err := validate.Required("requiresConfirmation", "body", m.RequiresConfirmation); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this register response based on context it is used +func (m *RegisterResponse) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *RegisterResponse) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *RegisterResponse) UnmarshalBinary(b []byte) error { + var res RegisterResponse + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/types/spec_handlers.go b/internal/types/spec_handlers.go new file mode 100644 index 0000000..9dd7a86 --- /dev/null +++ b/internal/types/spec_handlers.go @@ -0,0 +1,57 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package types + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +// NewSwaggerSpec creates a new SwaggerSpec instance +func NewSwaggerSpec() *SwaggerSpec { + + spec := &SwaggerSpec{ + Handlers: make(map[string]map[string]bool), + } + + if len(spec.Handlers) == 0 { + spec.initHandlerCache() + } + + return spec +} + +/*SwaggerSpec API documentation */ +type SwaggerSpec struct { + Handlers map[string]map[string]bool +} + +func (o *SwaggerSpec) initHandlerCache() { + o.Handlers = make(map[string]map[string]bool) + + // https://swagger.io/specification/v2/ fixed fields: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH + o.Handlers["GET"] = make(map[string]bool) + o.Handlers["PUT"] = make(map[string]bool) + o.Handlers["POST"] = make(map[string]bool) + o.Handlers["DELETE"] = make(map[string]bool) + o.Handlers["OPTIONS"] = make(map[string]bool) + o.Handlers["HEAD"] = make(map[string]bool) + o.Handlers["PATCH"] = make(map[string]bool) + + o.Handlers["DELETE"]["/api/v1/auth/account"] = true + o.Handlers["GET"]["/.well-known/assetlinks.json"] = true + o.Handlers["GET"]["/.well-known/apple-app-site-association"] = true + o.Handlers["GET"]["/api/v1/auth/register"] = true + o.Handlers["GET"]["/-/healthy"] = true + o.Handlers["GET"]["/-/ready"] = true + o.Handlers["GET"]["/swagger.yml"] = true + o.Handlers["GET"]["/api/v1/auth/userinfo"] = true + o.Handlers["GET"]["/-/version"] = true + o.Handlers["POST"]["/api/v1/auth/change-password"] = true + o.Handlers["POST"]["/api/v1/auth/register/{registrationToken}"] = true + o.Handlers["POST"]["/api/v1/auth/forgot-password/complete"] = true + o.Handlers["POST"]["/api/v1/auth/forgot-password"] = true + o.Handlers["POST"]["/api/v1/auth/login"] = true + o.Handlers["POST"]["/api/v1/auth/logout"] = true + o.Handlers["POST"]["/api/v1/auth/refresh"] = true + o.Handlers["POST"]["/api/v1/auth/register"] = true + o.Handlers["PUT"]["/api/v1/push/token"] = true +} diff --git a/internal/types/well_known/get_android_digital_asset_links_route_parameters.go b/internal/types/well_known/get_android_digital_asset_links_route_parameters.go new file mode 100644 index 0000000..b4a9c09 --- /dev/null +++ b/internal/types/well_known/get_android_digital_asset_links_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package well_known + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetAndroidDigitalAssetLinksRouteParams creates a new GetAndroidDigitalAssetLinksRouteParams object +// no default values defined in spec. +func NewGetAndroidDigitalAssetLinksRouteParams() GetAndroidDigitalAssetLinksRouteParams { + + return GetAndroidDigitalAssetLinksRouteParams{} +} + +// GetAndroidDigitalAssetLinksRouteParams contains all the bound params for the get android digital asset links route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetAndroidDigitalAssetLinksRoute +type GetAndroidDigitalAssetLinksRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetAndroidDigitalAssetLinksRouteParams() beforehand. +func (o *GetAndroidDigitalAssetLinksRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetAndroidDigitalAssetLinksRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/types/well_known/get_apple_app_site_association_route_parameters.go b/internal/types/well_known/get_apple_app_site_association_route_parameters.go new file mode 100644 index 0000000..b591566 --- /dev/null +++ b/internal/types/well_known/get_apple_app_site_association_route_parameters.go @@ -0,0 +1,55 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package well_known + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetAppleAppSiteAssociationRouteParams creates a new GetAppleAppSiteAssociationRouteParams object +// no default values defined in spec. +func NewGetAppleAppSiteAssociationRouteParams() GetAppleAppSiteAssociationRouteParams { + + return GetAppleAppSiteAssociationRouteParams{} +} + +// GetAppleAppSiteAssociationRouteParams contains all the bound params for the get apple app site association route operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetAppleAppSiteAssociationRoute +type GetAppleAppSiteAssociationRouteParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetAppleAppSiteAssociationRouteParams() beforehand. +func (o *GetAppleAppSiteAssociationRouteParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *GetAppleAppSiteAssociationRouteParams) Validate(formats strfmt.Registry) error { + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/util/bool.go b/internal/util/bool.go new file mode 100644 index 0000000..af834ab --- /dev/null +++ b/internal/util/bool.go @@ -0,0 +1,11 @@ +// nolint:revive +package util + +// FalseIfNil returns false if the passed pointer is nil. Passing a pointer to a bool will return the value of the bool. +func FalseIfNil(b *bool) bool { + if b == nil { + return false + } + + return *b +} diff --git a/internal/util/bool_test.go b/internal/util/bool_test.go new file mode 100644 index 0000000..5ccbff0 --- /dev/null +++ b/internal/util/bool_test.go @@ -0,0 +1,16 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestFalseIfNil(t *testing.T) { + b := true + assert.True(t, util.FalseIfNil(&b)) + b = false + assert.False(t, util.FalseIfNil(&b)) + assert.False(t, util.FalseIfNil(nil)) +} diff --git a/internal/util/cache_control.go b/internal/util/cache_control.go new file mode 100644 index 0000000..5f01529 --- /dev/null +++ b/internal/util/cache_control.go @@ -0,0 +1,69 @@ +// nolint:revive +package util + +import ( + "context" + "strings" +) + +type CacheControlDirective uint8 + +const ( + CacheControlDirectiveNoCache CacheControlDirective = 1 << iota + CacheControlDirectiveNoStore +) + +func (d *CacheControlDirective) HasDirective(dir CacheControlDirective) bool { return *d&dir != 0 } +func (d *CacheControlDirective) AddDirective(dir CacheControlDirective) { *d |= dir } +func (d *CacheControlDirective) ClearDirective(dir CacheControlDirective) { *d &= ^dir } +func (d *CacheControlDirective) ToggleDirective(dir CacheControlDirective) { *d ^= dir } + +func (d *CacheControlDirective) String() string { + res := make([]string, 0) + + if d.HasDirective(CacheControlDirectiveNoCache) { + res = append(res, "no-cache") + } + if d.HasDirective(CacheControlDirectiveNoStore) { + res = append(res, "no-store") + } + + return strings.Join(res, "|") +} + +func ParseCacheControlDirective(d string) CacheControlDirective { + parts := strings.Split(d, "=") + switch strings.ToLower(parts[0]) { + case "no-cache": + return CacheControlDirectiveNoCache + case "no-store": + return CacheControlDirectiveNoStore + default: + return 0 + } +} + +func ParseCacheControlHeader(val string) CacheControlDirective { + res := CacheControlDirective(0) + + directives := strings.Split(val, ",") + for _, dir := range directives { + res |= ParseCacheControlDirective(dir) + } + + return res +} + +func CacheControlDirectiveFromContext(ctx context.Context) CacheControlDirective { + d := ctx.Value(CTXKeyCacheControl) + if d == nil { + return CacheControlDirective(0) + } + + directive, ok := d.(CacheControlDirective) + if !ok { + return CacheControlDirective(0) + } + + return directive +} diff --git a/internal/util/cache_control_test.go b/internal/util/cache_control_test.go new file mode 100644 index 0000000..313cb53 --- /dev/null +++ b/internal/util/cache_control_test.go @@ -0,0 +1,77 @@ +package util_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/middleware" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCacheControl(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + path := "/testing-1c2cad5f-7545-4177-9bfe-8dc7ed368b33" + + s.Echo.GET(path, func(c echo.Context) error { + cache := util.CacheControlDirectiveFromContext(c.Request().Context()) + + switch { + case cache.HasDirective(util.CacheControlDirectiveNoCache) && cache.HasDirective(util.CacheControlDirectiveNoStore): + return c.JSON(http.StatusOK, "no-cache,no-store") + case cache.HasDirective(util.CacheControlDirectiveNoCache): + return c.JSON(http.StatusOK, "no-cache") + case cache.HasDirective(util.CacheControlDirectiveNoStore): + return c.JSON(http.StatusOK, "no-store") + } + + return c.NoContent(http.StatusNoContent) + }, middleware.CacheControl()) + + cacheControlNoCache := util.CacheControlDirectiveNoCache + cacheControlNoStore := util.CacheControlDirectiveNoStore + + header := http.Header{} + header.Set(util.HTTPHeaderCacheControl, fmt.Sprintf("%s,%s", cacheControlNoStore.String(), cacheControlNoCache.String())) + + res := test.PerformRequest(t, s, "GET", path, nil, header) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var resp string + err := json.NewDecoder(res.Result().Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "no-cache,no-store", resp) + + header.Set(util.HTTPHeaderCacheControl, cacheControlNoCache.String()) + + res = test.PerformRequest(t, s, "GET", path, nil, header) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + err = json.NewDecoder(res.Result().Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "no-cache", resp) + + header.Set(util.HTTPHeaderCacheControl, cacheControlNoStore.String()) + + res = test.PerformRequest(t, s, "GET", path, nil, header) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + err = json.NewDecoder(res.Result().Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "no-store", resp) + + res = test.PerformRequest(t, s, "GET", path, nil, nil) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + + header.Set(util.HTTPHeaderCacheControl, "gunther") + + res = test.PerformRequest(t, s, "GET", path, nil, header) + assert.Equal(t, http.StatusNoContent, res.Result().StatusCode) + }) +} diff --git a/internal/util/command/command.go b/internal/util/command/command.go new file mode 100644 index 0000000..ced295f --- /dev/null +++ b/internal/util/command/command.go @@ -0,0 +1,76 @@ +package command + +import ( + "context" + "errors" + "fmt" + "time" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/config" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +const ( + LogKeyCmdExecutionID = "cmdExecutionId" + shutdownTimeout = 30 * time.Second +) + +func WithServer(ctx context.Context, config config.Server, handler func(ctx context.Context, s *api.Server) error) error { + ctx = log.With().Str(LogKeyCmdExecutionID, uuid.New().String()).Logger().WithContext(ctx) + + zerolog.TimeFieldFormat = time.RFC3339Nano + zerolog.SetGlobalLevel(config.Logger.Level) + if config.Logger.PrettyPrintConsole { + log.Logger = log.Output(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.TimeFormat = "15:04:05" + })) + } + + s, err := api.InitNewServer(config) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize server") + } + + start := s.Clock.Now() + + err = handler(ctx, s) + + elapsed := time.Since(start) + log.Info().Dur("duration", elapsed).Msg("Command execution finished") + + if err != nil { + log.Error().Err(err).Msg("Command failed") + return err + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if errs := s.Shutdown(shutdownCtx); len(errs) > 0 { + log.Error().Errs("shutdownErrors", errs).Msg("Failed to gracefully shut down server") + return errors.Join(errs...) + } + + return nil +} + +func NewSubcommandGroup(subcommand string, subcommands ...*cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("%s ", subcommand), + Short: fmt.Sprintf("%s related subcommands", subcommand), + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Help(); err != nil { + return fmt.Errorf("failed to print help: %w", err) + } + + return nil + }, + } + + cmd.AddCommand(subcommands...) + + return cmd +} diff --git a/internal/util/command/command_test.go b/internal/util/command/command_test.go new file mode 100644 index 0000000..e40f5cd --- /dev/null +++ b/internal/util/command/command_test.go @@ -0,0 +1,34 @@ +package command_test + +import ( + "context" + "errors" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util/command" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithServer(t *testing.T) { + test.WithTestServer(t, func(s *api.Server) { + ctx := t.Context() + + var testError = errors.New("test error") + + s.Config.Logger.PrettyPrintConsole = false + resultErr := command.WithServer(ctx, s.Config, func(ctx context.Context, s *api.Server) error { + var database string + err := s.DB.QueryRowContext(ctx, "SELECT current_database();").Scan(&database) + require.NoError(t, err) + + assert.NotEmpty(t, database) + + return testError + }) + + assert.Equal(t, testError, resultErr) + }) +} diff --git a/internal/util/context.go b/internal/util/context.go new file mode 100644 index 0000000..ed821d4 --- /dev/null +++ b/internal/util/context.go @@ -0,0 +1,77 @@ +// nolint:revive +package util + +import ( + "context" + "errors" + "time" +) + +type contextKey string + +const ( + CTXKeyUser contextKey = "user" + CTXKeyAccessToken contextKey = "access_token" + CTXKeyCacheControl contextKey = "cache_control" + CTXKeyRequestID contextKey = "request_id" + CTXKeyDisableLogger contextKey = "disable_logger" +) + +//nolint:containedctx +type detachedContext struct { + parent context.Context +} + +func (c detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (c detachedContext) Done() <-chan struct{} { return nil } +func (c detachedContext) Err() error { return nil } +func (c detachedContext) Value(key interface{}) interface{} { return c.parent.Value(key) } + +// DetachContext detaches a context by returning a wrapped struct implementing the context interface, but omitting the deadline, done and error functionality. +// Mainly used to pass context information to go routines that should not be cancelled by the context. +// ! USE THIS DETACHED CONTEXT SPARINGLY, ONLY IF ABSOLUTELY NEEDED. DO *NOT* KEEP USING A DETACHED CONTEXT FOR A PROLONGED TIME OUT OF CHAIN +func DetachContext(ctx context.Context) context.Context { + return detachedContext{ctx} +} + +// RequestIDFromContext returns the ID of the (HTTP) request, returning an error if it is not present. +func RequestIDFromContext(ctx context.Context) (string, error) { + val := ctx.Value(CTXKeyRequestID) + if val == nil { + return "", errors.New("no request id present in context") + } + + id, ok := val.(string) + if !ok { + return "", errors.New("request id in context is not a string") + } + + return id, nil +} + +// ShouldDisableLogger checks whether the logger instance should be disabled for the provided context. +// `util.LogFromContext` will use this function to check whether it should return a default logger if +// none has been set by our logging middleware before, or fall back to the disabled logger, suppressing +// all output. Use `ctx = util.DisableLogger(ctx, true)` to disable logging for the given context. +func ShouldDisableLogger(ctx context.Context) bool { + s := ctx.Value(CTXKeyDisableLogger) + if s == nil { + return false + } + + shouldDisable, ok := s.(bool) + if !ok { + return false + } + + return shouldDisable +} + +// DisableLogger toggles the indication whether `util.LogFromContext` should return a disabled logger +// for a context if none has been set by our logging middleware before. Whilst the usecase for a disabled +// logger are relatively minimal (we almost always want to have some log output, even if the context +// was not directly derived from a HTTP request), this functionality was provideds so you can switch back +// to the old zerolog behavior if so desired. +func DisableLogger(ctx context.Context, shouldDisable bool) context.Context { + return context.WithValue(ctx, CTXKeyDisableLogger, shouldDisable) +} diff --git a/internal/util/context_test.go b/internal/util/context_test.go new file mode 100644 index 0000000..ccc5ed8 --- /dev/null +++ b/internal/util/context_test.go @@ -0,0 +1,86 @@ +package util_test + +import ( + "context" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type contextKey string + +func TestDetachContextWithCancel(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + var key contextKey = "test" + val := 42 + ctx2 := context.WithValue(ctx, key, val) + detachedContext := util.DetachContext(ctx2) + + cancel() + + select { + case <-ctx.Done(): + t.Log("Context cancelled") + default: + t.Error("Context is not canceled") + } + + select { + case <-ctx2.Done(): + t.Log("Context with value cancelled") + default: + t.Error("Context with value is not canceled") + } + + select { + case <-detachedContext.Done(): + t.Error("Detached context is cancelled") + default: + t.Log("Detached context is not cancelled") + } + + res, ok := detachedContext.Value(key).(int) + require.True(t, ok) + assert.Equal(t, val, res) +} + +func TestDetachContextWithDeadline(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second*1) + defer cancel() + + var key contextKey = "test" + val := 42 + ctx2 := context.WithValue(ctx, key, val) + detachedContext := util.DetachContext(ctx2) + + time.Sleep(time.Second * 2) + + select { + case <-ctx.Done(): + t.Log("Context cancelled") + default: + t.Error("Context is not canceled") + } + + select { + case <-ctx2.Done(): + t.Log("Context with value cancelled") + default: + t.Error("Context with value is not canceled") + } + + select { + case <-detachedContext.Done(): + t.Error("Detached context is cancelled") + default: + t.Log("Detached context is not cancelled") + } + + res, ok := detachedContext.Value(key).(int) + require.True(t, ok) + assert.Equal(t, val, res) +} diff --git a/internal/util/currency.go b/internal/util/currency.go new file mode 100644 index 0000000..7433c6c --- /dev/null +++ b/internal/util/currency.go @@ -0,0 +1,60 @@ +// nolint:revive +package util + +import "github.com/go-openapi/swag" + +const ( + centFactor = 100 +) + +func Int64PtrWithCentsToFloat64Ptr(c *int64) *float64 { + if c == nil { + return nil + } + + return Int64WithCentsToFloat64Ptr(*c) +} + +func Int64WithCentsToFloat64Ptr(c int64) *float64 { + return swag.Float64(float64(c) / centFactor) +} + +func IntPtrWithCentsToFloat64Ptr(c *int) *float64 { + if c == nil { + return nil + } + + return IntWithCentsToFloat64Ptr(*c) +} + +func IntWithCentsToFloat64Ptr(c int) *float64 { + return swag.Float64(float64(c) / centFactor) +} + +func Float64PtrToInt64PtrWithCents(f *float64) *int64 { + if f == nil { + return nil + } + + return swag.Int64(Float64PtrToInt64WithCents(f)) +} + +func Float64PtrToInt64WithCents(f *float64) int64 { + return int64(swag.Float64Value(f) * centFactor) +} + +func Float64ToInt64WithCents(f float64) int64 { + return int64(f * centFactor) +} + +func Float64PtrToIntPtrWithCents(f *float64) *int { + if f == nil { + return nil + } + + return swag.Int(Float64PtrToIntWithCents(f)) +} + +func Float64PtrToIntWithCents(f *float64) int { + return int(swag.Float64Value(f) * centFactor) +} diff --git a/internal/util/currency_test.go b/internal/util/currency_test.go new file mode 100644 index 0000000..70ff5e0 --- /dev/null +++ b/internal/util/currency_test.go @@ -0,0 +1,66 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestCurrencyConversion(t *testing.T) { + tests := []struct { + name string + val int + }{ + { + name: "1", + val: 999999999999999, // this is the max size with accurate precision + }, + { + name: "2", + val: 0, + }, + { + name: "3", + val: -999999999999999, + }, + { + name: "4", + val: 3333333333333333, + }, + { + name: "5", + val: 1111111111111111, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := int64(tt.val) + res := util.Int64PtrWithCentsToFloat64Ptr(&in) + out := util.Float64PtrToInt64PtrWithCents(res) + assert.Equal(t, in, *out) + + inInt := int(in) + res = util.IntPtrWithCentsToFloat64Ptr(&inInt) + outInt := util.Float64PtrToIntPtrWithCents(res) + assert.Equal(t, inInt, *outInt) + outInt2 := util.Float64ToInt64WithCents(*res) + assert.Equal(t, int64(inInt), outInt2) + }) + } +} + +func TestCurrencyConversionNil(t *testing.T) { + res := util.Int64PtrWithCentsToFloat64Ptr(nil) + assert.Nil(t, res) + + res = util.IntPtrWithCentsToFloat64Ptr(nil) + assert.Nil(t, res) + + res2 := util.Float64PtrToInt64PtrWithCents(nil) + assert.Nil(t, res2) + + res3 := util.Float64PtrToIntPtrWithCents(nil) + assert.Nil(t, res3) +} diff --git a/internal/util/db/db.go b/internal/util/db/db.go new file mode 100644 index 0000000..9cb91d7 --- /dev/null +++ b/internal/util/db/db.go @@ -0,0 +1,109 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "math" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" +) + +type TxFn func(boil.ContextExecutor) error + +func WithTransaction(ctx context.Context, db *sql.DB, txHandler TxFn) error { + return WithConfiguredTransaction(ctx, db, nil, txHandler) +} + +func WithConfiguredTransaction(ctx context.Context, db *sql.DB, options *sql.TxOptions, txHandler TxFn) error { + tx, err := db.BeginTx(ctx, options) + if err != nil { + util.LogFromContext(ctx).Warn().Err(err).Msg("Failed to start transaction") + return fmt.Errorf("failed to start transaction: %w", err) + } + + defer func() { + cause := recover() + + switch { + case cause != nil: + util.LogFromContext(ctx).Error().Interface("cause", cause).Msg("Recovered from panic, rolling back transaction and panicking again") + + if txErr := tx.Rollback(); txErr != nil { + util.LogFromContext(ctx).Warn().Err(txErr).Msg("Failed to roll back transaction after recovering from panic") + } + + panic(cause) + case err != nil: + util.LogFromContext(ctx).Warn().Err(err).Msg("Received error, rolling back transaction") + + if txErr := tx.Rollback(); txErr != nil { + util.LogFromContext(ctx).Warn().Err(txErr).Msg("Failed to roll back transaction after receiving error") + } + default: + err = tx.Commit() + if err != nil { + util.LogFromContext(ctx).Warn().Err(err).Msg("Failed to commit transaction") + } + } + }() + + err = txHandler(tx) + if err != nil { + return fmt.Errorf("failed to execute transaction: %w", err) + } + + return nil +} + +func NullIntFromInt64Ptr(i *int64) null.Int { + if i == nil { + return null.NewInt(0, false) + } + + return null.NewInt(int(*i), true) +} + +func NullFloat32FromFloat64Ptr(f *float64) null.Float32 { + if f == nil { + return null.NewFloat32(0.0, false) + } + + return null.NewFloat32(float32(*f), true) +} + +func NullIntFromInt16Ptr(i *int16) null.Int { + if i == nil { + return null.NewInt(0, false) + } + + return null.NewInt(int(*i), true) +} + +func Int16PtrFromNullInt(i null.Int) *int16 { + if !i.Valid || i.Int > math.MaxInt16 || i.Int < math.MinInt16 { + return nil + } + + res := int16(i.Int) + return &res +} + +func Int16PtrFromInt(i int) *int16 { + if i > math.MaxInt16 || i < math.MinInt16 { + return nil + } + + res := int16(i) + return &res +} + +func NullStringIfEmpty(s string) null.String { + if len(s) == 0 { + return null.String{} + } + + return null.StringFrom(s) +} diff --git a/internal/util/db/db_test.go b/internal/util/db/db_test.go new file mode 100644 index 0000000..b6d9082 --- /dev/null +++ b/internal/util/db/db_test.go @@ -0,0 +1,190 @@ +package db_test + +import ( + "database/sql" + "fmt" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithTransactionSuccess(t *testing.T) { + test.WithTestDatabase(t, func(sqlDB *sql.DB) { + ctx := t.Context() + + count, err := models.Users().Count(ctx, sqlDB) + require.NoError(t, err) + assert.Positive(t, count) + + err = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error { + newUser := models.User{ + IsActive: true, + Username: null.StringFrom("test"), + Scopes: types.StringArray{"cms"}, + } + + if err := newUser.Insert(ctx, tx, boil.Infer()); err != nil { + return fmt.Errorf("failed to insert user: %w", err) + } + + newCount, err := models.Users().Count(ctx, tx) + require.NoError(t, err) + assert.Equal(t, count+1, newCount) + + delCnt, err := models.Users().DeleteAll(ctx, tx) + if err != nil { + return fmt.Errorf("failed to delete all users: %w", err) + } + assert.Equal(t, newCount, delCnt) + + newCount, err = models.Users().Count(ctx, tx) + require.NoError(t, err) + assert.Equal(t, int64(0), newCount) + + return nil + }) + require.NoError(t, err) + + newCount, err := models.Users().Count(ctx, sqlDB) + require.NoError(t, err) + assert.Equal(t, int64(0), newCount) + }) +} + +func TestWithTransactionWithError(t *testing.T) { + test.WithTestDatabase(t, func(sqlDB *sql.DB) { + ctx := t.Context() + + count, err := models.Users().Count(ctx, sqlDB) + require.NoError(t, err) + assert.Positive(t, count) + + err = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error { + newUser := models.User{ + IsActive: true, + Username: null.StringFrom("test"), + Scopes: types.StringArray{"cms"}, + } + + err := newUser.Insert(ctx, tx, boil.Infer()) + require.NoError(t, err) + + newCount, err := models.Users().Count(ctx, tx) + require.NoError(t, err) + assert.Equal(t, count+1, newCount) + + delCnt, err := models.Users().DeleteAll(ctx, tx) + require.NoError(t, err) + assert.Equal(t, newCount, delCnt) + + newCount, err = models.Users().Count(ctx, tx) + require.NoError(t, err) + assert.Equal(t, int64(0), newCount) + + newUser2 := models.User{ + IsActive: true, + Username: null.StringFrom("test"), + } + + return newUser2.Insert(ctx, tx, boil.Infer()) + }) + require.Error(t, err) + + newCount, err := models.Users().Count(ctx, sqlDB) + require.NoError(t, err) + assert.Equal(t, count, newCount) + }) +} + +func TestWithTransactionWithPanic(t *testing.T) { + test.WithTestDatabase(t, func(sqlDB *sql.DB) { + ctx := t.Context() + + count, err := models.Users().Count(ctx, sqlDB) + require.NoError(t, err) + assert.Positive(t, count) + + panicFunc := func() { + _ = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error { + newUser := models.User{ + IsActive: true, + Username: null.StringFrom("test"), + Scopes: types.StringArray{"cms"}, + } + + err := newUser.Insert(ctx, tx, boil.Infer()) + require.NoError(t, err) + + newCount, err := models.Users().Count(ctx, tx) + require.NoError(t, err) + assert.Equal(t, count+1, newCount) + + delCnt, err := models.Users().DeleteAll(ctx, tx) + require.NoError(t, err) + assert.Equal(t, newCount, delCnt) + + newCount, err = models.Users().Count(ctx, tx) + require.NoError(t, err) + assert.Equal(t, int64(0), newCount) + + panic("some panic") + }) + } + + require.Panics(t, panicFunc) + + newCount, err := models.Users().Count(ctx, sqlDB) + require.NoError(t, err) + assert.Equal(t, count, newCount) + }) +} + +func TestDBTypeConversions(t *testing.T) { + i64 := int64(19) + res := db.NullIntFromInt64Ptr(&i64) + assert.Equal(t, 19, res.Int) + assert.True(t, res.Valid) + + res = db.NullIntFromInt64Ptr(nil) + assert.False(t, res.Valid) + + f := 19.9999 + res2 := db.NullFloat32FromFloat64Ptr(&f) + assert.InDelta(t, float32(f), res2.Float32, 0.0001) + assert.True(t, res2.Valid) + + res2 = db.NullFloat32FromFloat64Ptr(nil) + assert.False(t, res2.Valid) + + i16 := int16(19) + res3 := db.NullIntFromInt16Ptr(&i16) + assert.Equal(t, 19, res3.Int) + assert.True(t, res3.Valid) + + res4 := db.Int16PtrFromNullInt(res3) + require.NotEmpty(t, res4) + assert.Equal(t, i16, *res4) + + res5 := db.Int16PtrFromNullInt(null.IntFromPtr(nil)) + assert.Empty(t, res5) + + i := 7 + res6 := db.Int16PtrFromInt(i) + require.NotEmpty(t, res6) + assert.Equal(t, i, int(*res6)) + + res7 := db.NullStringIfEmpty("") + assert.False(t, res7.Valid) + + s := "foo" + res8 := db.NullStringIfEmpty(s) + assert.True(t, res8.Valid) + assert.Equal(t, s, res8.String) +} diff --git a/internal/util/db/example_test.go b/internal/util/db/example_test.go new file mode 100644 index 0000000..ce3311c --- /dev/null +++ b/internal/util/db/example_test.go @@ -0,0 +1,69 @@ +package db_test + +import ( + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +type PublicName struct { + First string `json:"firstName"` +} + +type Name struct { + PublicName + MiddleName string `json:"-"` + Lastname string `json:"lastName"` +} + +type UserFilter struct { + Name + Country string `json:"country"` + City string + Scopes []string `json:"scopes"` + Age *int `json:"age"` + Height *float32 `json:"height"` +} + +func ExampleWhereJSON() { + age := 42 + filter := UserFilter{ + Name: Name{ + PublicName: PublicName{ + First: "Max", + }, + MiddleName: "Gustav", + Lastname: "Muster", + }, + Country: "Austria", + City: "Vienna", + Scopes: []string{"app", "user_info"}, + Age: &age, + } + + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.WhereJSON("users", "profile", filter), + ) + + sql, args := queries.BuildQuery(query) + + fmt.Println(sql) + fmt.Print("[") + for i := range args { + if i < len(args)-1 { + fmt.Printf("%v, ", args[i]) + } else { + fmt.Printf("%v", args[i]) + } + } + fmt.Println("]") + + // Output: + // SELECT * FROM "users" WHERE (users.profile->>'firstName' = $1 AND users.profile->>'lastName' = $2 AND users.profile->>'country' = $3 AND users.profile->'scopes' <@ to_jsonb($4::text[]) AND users.profile->>'age' = $5); + // [Max, Muster, Austria, &[app user_info], 42] +} diff --git a/internal/util/db/ilike.go b/internal/util/db/ilike.go new file mode 100644 index 0000000..6320f7f --- /dev/null +++ b/internal/util/db/ilike.go @@ -0,0 +1,47 @@ +package db + +import ( + "fmt" + "regexp" + "strings" + + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +var ( + likeQueryEscapeRegex = regexp.MustCompile(`(%|_)`) + likeQueryWhiteSpaceRegex = regexp.MustCompile(`\s+`) +) + +// ILike returns a query mod containing a pre-formatted ILIKE clause. +// The value provided is applied directly - to perform a wildcard search, +// enclose the desired search value in `%` as desired before passing it +// to ILike. +// The path provided will be joined to construct the full SQL path used, +// allowing for filtering of values nested across multiple joins if needed. +func ILike(val string, path ...string) qm.QueryMod { + // ! Attention: we **must** use ? instead of $1 or similar to bind query parameters here since + // ! other parts of the query might have already defined $1, leading to incorrect parameters + // ! being inserted. On the contrary to other parts using PG queries, ? actually works with qm.Where. + return qm.Where(fmt.Sprintf("%s ILIKE ?", strings.Join(path, ".")), val) +} + +// ILikeSearch returns a query mod with one or multiple ILIKE clauses in an +// AND expression. +// The query is split on whitespace characters and for each word an escaped +// ILIKE with prefix and suffix wildcard will be generated. +func ILikeSearch(query string, path ...string) qm.QueryMod { + res := []qm.QueryMod{} + + terms := likeQueryWhiteSpaceRegex.Split(strings.TrimSpace(query), -1) + for _, t := range terms { + res = append(res, ILike("%"+EscapeLike(t)+"%", path...)) + } + + return qm.Expr(res...) +} + +// EscapeLike escapes a string to be placed in an ILIKE query. +func EscapeLike(val string) string { + return likeQueryEscapeRegex.ReplaceAllString(val, "\\$1") +} diff --git a/internal/util/db/ilike_test.go b/internal/util/db/ilike_test.go new file mode 100644 index 0000000..543c7c5 --- /dev/null +++ b/internal/util/db/ilike_test.go @@ -0,0 +1,46 @@ +package db_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/stretchr/testify/assert" +) + +func TestILike(t *testing.T) { + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.InnerJoin("users", "id", "app_user_profiles", "user_id"), + db.ILike("%Max.Muster%", "users", "username"), + db.ILike("Max", "users", "app_user_profiles", "first_name"), + ) + + sql, args := queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestEscapeLike(t *testing.T) { + res := db.EscapeLike("%foo% _b%a_r%") + assert.Equal(t, "\\%foo\\% \\_b\\%a\\_r\\%", res) +} + +func TestILikeSearch(t *testing.T) { + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.InnerJoin("users", "id", "app_user_profiles", "user_id"), + db.ILikeSearch(" mus%ter m_ax ", "users", "username"), + ) + + sql, args := queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} diff --git a/internal/util/db/join.go b/internal/util/db/join.go new file mode 100644 index 0000000..96cdfa6 --- /dev/null +++ b/internal/util/db/join.go @@ -0,0 +1,63 @@ +package db + +import ( + "fmt" + + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +// InnerJoinWithFilter returns an InnerJoin QueryMod formatted using the provided join tables and columns including an +// additional filter condition. Omitting the optional filter table will use the provided join table as a base for the filter. +func InnerJoinWithFilter(baseTable string, baseColumn string, joinTable string, joinColumn string, filterColumn string, filterValue interface{}, optFilterTable ...string) qm.QueryMod { + filterTable := joinTable + if len(optFilterTable) > 0 { + filterTable = optFilterTable[0] + } + + return qm.InnerJoin(fmt.Sprintf("%s ON %s.%s=%s.%s AND %s.%s=?", + joinTable, + joinTable, + joinColumn, + baseTable, + baseColumn, + filterTable, + filterColumn), filterValue) +} + +// InnerJoin returns an InnerJoin QueryMod formatted using the provided join tables and columns. +func InnerJoin(baseTable string, baseColumn string, joinTable string, joinColumn string) qm.QueryMod { + return qm.InnerJoin(fmt.Sprintf("%s ON %s.%s=%s.%s", + joinTable, + joinTable, + joinColumn, + baseTable, + baseColumn)) +} + +// LeftOuterJoin returns an LeftOuterJoin QueryMod formatted using the provided join tables and columns. +func LeftOuterJoin(baseTable string, baseColumn string, joinTable string, joinColumn string) qm.QueryMod { + return qm.LeftOuterJoin(fmt.Sprintf("%s ON %s.%s=%s.%s", + joinTable, + joinTable, + joinColumn, + baseTable, + baseColumn)) +} + +// LeftOuterJoinWithFilter returns an LeftOuterJoin QueryMod formatted using the provided join tables and columns including an +// additional filter condition. Omitting the optional filter table will use the provided join table as a base for the filter. +func LeftOuterJoinWithFilter(baseTable string, baseColumn string, joinTable string, joinColumn string, filterColumn string, filterValue interface{}, optFilterTable ...string) qm.QueryMod { + filterTable := joinTable + if len(optFilterTable) > 0 { + filterTable = optFilterTable[0] + } + + return qm.LeftOuterJoin(fmt.Sprintf("%s ON %s.%s=%s.%s AND %s.%s=?", + joinTable, + joinTable, + joinColumn, + baseTable, + baseColumn, + filterTable, + filterColumn), filterValue) +} diff --git a/internal/util/db/join_test.go b/internal/util/db/join_test.go new file mode 100644 index 0000000..acbd36a --- /dev/null +++ b/internal/util/db/join_test.go @@ -0,0 +1,104 @@ +package db_test + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInnerJoinWithFilter(t *testing.T) { + test.WithTestDatabase(t, func(sqlDB *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + profiles, err := models.AppUserProfiles(db.InnerJoinWithFilter(models.TableNames.AppUserProfiles, + models.AppUserProfileColumns.UserID, + models.TableNames.Users, + models.UserColumns.ID, + models.UserColumns.Username, + "user1@example.com", + )).All(ctx, sqlDB) + require.NoError(t, err) + require.Len(t, profiles, 1) + + assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID) + + profiles, err = models.AppUserProfiles(db.InnerJoinWithFilter(models.TableNames.AppUserProfiles, + models.AppUserProfileColumns.UserID, + models.TableNames.Users, + models.UserColumns.ID, + models.UserColumns.Username, + "user1@example.com", + models.TableNames.Users, + )).All(ctx, sqlDB) + require.NoError(t, err) + require.Len(t, profiles, 1) + + assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID) + }) +} + +func TestInnerJoin(t *testing.T) { + test.WithTestDatabase(t, func(sqlDB *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + profiles, err := models.AppUserProfiles(db.InnerJoin(models.TableNames.AppUserProfiles, + models.AppUserProfileColumns.UserID, + models.TableNames.Users, + models.UserColumns.ID, + ), + models.UserWhere.Username.EQ(null.StringFrom("user1@example.com")), + ).All(ctx, sqlDB) + require.NoError(t, err) + require.Len(t, profiles, 1) + + assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID) + }) +} + +func TestLeftOuterJoinWithFilter(t *testing.T) { + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.LeftOuterJoinWithFilter("users", "id", "app_user_profiles", "user_id", "first_name", "Max"), + ) + + sql, args := queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) + + query = models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.LeftOuterJoinWithFilter("users", "id", "app_user_profiles", "user_id", "first_name", "Max", "app_user_profiles"), + ) + + sql, args = queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestLeftOuterJoin(t *testing.T) { + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.LeftOuterJoin("users", "id", "app_user_profiles", "user_id"), + ) + + sql, args := queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} diff --git a/internal/util/db/json.go b/internal/util/db/json.go new file mode 100644 index 0000000..1b5387b --- /dev/null +++ b/internal/util/db/json.go @@ -0,0 +1,126 @@ +package db + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/lib/pq" +) + +const ( + whereJSONMaxLevel = 10 +) + +// WhereJSON constructs a QueryMod for querying a JSONB column. +// +// The filter interface provided is inspected using reflection, all fields with +// a (non-empty) `json` tag will be added to the query and combined using `AND` - +// fields tagged with `json:"-"` will be ignored as well. Alternatively, a string +// can be provided, performing a string comparison with the database value (the +// stored JSON value does not necessarily have to be a string, but could be an +// integer or similar). The `json` tag's (first) value will be used as the "key" +// for the query, allowing for field renaming or different capitalizations. +// +// At the moment, the root level `filter` value must either be a struct or a string. +// WhereJSON will panic should it encounter a type it cannot process or the filter +// provided results in an empty QueryMod - this allows for easier call chaining +// at the expense of panics in case of incorrect filters being passed. +// +// WhereJSON should support all basic types as well as pointers and array/slices +// of those out of the box, given the Postgres driver can handle their serialization. +// nil pointers are skipped automatically. +// At the moment, struct fields are only supported for composition purposes: if a +// struct is encountered, WhereJSON recursively traverses it (up to 10 levels deep) +// and adds all eligible fields to the top level query. +// Should an array or slice be encountered, their values will be added using the +// `<@` JSONB operator, checking whether all entries existx at the top level within +// the JSON column. +// At the time of writing, no support for special database/HTTP types such as the +// `null` or `strfmt` packages exists - use their respective base types instead. +// +// Whilst WhereJSON was designed to be used with Postgres' JSONB column type, the +// current implementation also supports the JSON type as long as the filter struct +// does not contain any arrays or slices. Note that this compatibility might change +// at some point in the future, so it is advised to use the JSONB data type unless +// your requirements do not allow for it. +func WhereJSON(table string, column string, filter interface{}) qm.QueryMod { + qms := whereJSON(table, column, filter, 0) + if len(qms) == 0 { + panic(errors.New("filter resulted in empty query")) + } + + return qm.Expr(qms...) +} + +func whereJSON(table string, column string, filter interface{}, level int) []qm.QueryMod { + if level >= whereJSONMaxLevel { + panic(fmt.Errorf("whereJSON reached maximum recursion (%d/%d)", level, whereJSONMaxLevel)) + } + + qms := make([]qm.QueryMod, 0) + + filterType := reflect.TypeOf(filter) + switch filterType.Kind() { + case reflect.Struct: + filterValue := reflect.ValueOf(filter) + for i := 0; i < filterType.NumField(); i++ { + field := filterType.Field(i) + + // skip unexported fields as we cannot retrieve their values + if len(field.PkgPath) != 0 { + continue + } + + jsonkey := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonkey == "-" { + continue + } + + filterValueField := filterValue.Field(i) + if filterValueField.Kind() != reflect.Struct && jsonkey == "" { + continue + } + + isArray := false + var val interface{} + switch filterValueField.Kind() { + case reflect.Struct: + qms = append(qms, whereJSON(table, column, filterValueField.Interface(), level+1)...) + continue + case reflect.Ptr: + if !filterValueField.IsValid() || filterValueField.IsNil() { + continue + } + if filterValueField.Elem().Kind() == reflect.Array || + filterValueField.Elem().Kind() == reflect.Slice { + isArray = true + } + val = filterValueField.Elem().Interface() + case reflect.Array, + reflect.Slice: + if !filterValueField.IsValid() || filterValueField.IsNil() { + continue + } + isArray = true + val = filterValueField.Interface() + default: + val = filterValueField.Interface() + } + + if isArray { + qms = append(qms, qm.Where(fmt.Sprintf("%s.%s->'%s' <@ to_jsonb(?::text[])", table, column, jsonkey), pq.Array(val))) + } else { + qms = append(qms, qm.Where(fmt.Sprintf("%s.%s->>'%s' = ?", table, column, jsonkey), val)) + } + } + case reflect.String: + qms = append(qms, qm.Where(fmt.Sprintf("%s.%s::text = ?", table, column), filter)) + default: + panic(fmt.Errorf("invalid filter type %v", filterType.Kind())) + } + + return qms +} diff --git a/internal/util/db/json_test.go b/internal/util/db/json_test.go new file mode 100644 index 0000000..a44e45f --- /dev/null +++ b/internal/util/db/json_test.go @@ -0,0 +1,205 @@ +package db_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/stretchr/testify/require" +) + +func TestWhereJSONStruct(t *testing.T) { + age := 42 + filter := struct { + First string `json:"firstName"` + MiddleName string `json:"-"` + Lastname string `json:"lastName"` + Country string `json:"country"` + City string + Scopes []string `json:"scopes"` + Age *int `json:"age"` + Height *float32 `json:"height"` + PhoneNumbers *[2]string `json:"phoneNumbers"` + Addresses []string `json:"addresses"` + }{ + First: "Max", + MiddleName: "Gustav", + Lastname: "Muster", + Country: "Austria", + City: "Vienna", + Scopes: []string{"app", "user_info"}, + Age: &age, + PhoneNumbers: &[2]string{ + "+1 206 555 0100", + "+44 113 496 0000", + }, + } + + sql, args := buildWhereJSONQuery(t, filter) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestWhereJSONStructComposition(t *testing.T) { + age := 42 + filter := UserFilter{ + Name: Name{ + PublicName: PublicName{ + First: "Max", + }, + MiddleName: "Gustav", + Lastname: "Muster", + }, + Country: "Austria", + City: "Vienna", + Scopes: []string{"app", "user_info"}, + Age: &age, + } + + sql, args := buildWhereJSONQuery(t, filter) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestWhereJSONString(t *testing.T) { + sql, args := buildWhereJSONQuery(t, "https://example.org/users/123/profile") + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestWhereJSONPanicEmptyResult(t *testing.T) { + type privateName struct { + First string `json:"firstName"` + MiddleName string `json:"-"` + Lastname string `json:"lastName"` + } + + filter := struct { + privateName + City string + }{ + privateName: privateName{ + First: "Max", + MiddleName: "Gustav", + Lastname: "Muster", + }, + City: "Vienna", + } + + panicFunc := func() { + db.WhereJSON("users", "profile", filter) + } + + require.PanicsWithError(t, "filter resulted in empty query", panicFunc) +} + +func TestWhereJSONPanicInvalidFilterType(t *testing.T) { + panicFunc := func() { + db.WhereJSON("users", "profile", 1) + } + + require.PanicsWithError(t, "invalid filter type int", panicFunc) +} + +func TestWhereJSONPanicRecursion(t *testing.T) { + type A struct { + One string `json:"one"` + } + type B struct { + A + Two string `json:"two"` + } + type C struct { + B + Three string `json:"three"` + } + type D struct { + C + Four string `json:"four"` + } + type E struct { + D + Five string `json:"five"` + } + type F struct { + E + Six string `json:"six"` + } + type G struct { + F + Seven string `json:"seven"` + } + type H struct { + G + Eight string `json:"eight"` + } + type I struct { + H + Nine string `json:"nine"` + } + type J struct { + I + Ten string `json:"ten"` + } + + filter := struct { + J + Country string `json:"country"` + }{ + J: J{ + I: I{ + H: H{ + G: G{ + F: F{ + E: E{ + D: D{ + C: C{ + B: B{ + A: A{ + One: "1", + }, + Two: "2", + }, + Three: "3", + }, + Four: "4", + }, + Five: "5", + }, + Six: "6", + }, + Seven: "7", + }, + Eight: "8", + }, + Nine: "9", + }, + Ten: "10", + }, + Country: "Austria", + } + + panicFunc := func() { + db.WhereJSON("users", "profile", filter) + } + + require.PanicsWithError(t, "whereJSON reached maximum recursion (10/10)", panicFunc) +} + +func buildWhereJSONQuery(t *testing.T, filter interface{}) (string, []interface{}) { + t.Helper() + + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.WhereJSON("users", "profile", filter), + ) + + return queries.BuildQuery(query) +} diff --git a/internal/util/db/or.go b/internal/util/db/or.go new file mode 100644 index 0000000..901cbf0 --- /dev/null +++ b/internal/util/db/or.go @@ -0,0 +1,22 @@ +package db + +import "github.com/aarondl/sqlboiler/v4/queries/qm" + +// CombineWithOr receives a slice of query mods and returns a new slice with +// a single query mod, combining all other query mods into an OR expression. +func CombineWithOr(qms []qm.QueryMod) []qm.QueryMod { + if len(qms) == 0 { + return []qm.QueryMod{} + } + + if len(qms) == 1 { + return qms + } + + q := []qm.QueryMod{qms[0]} + for _, sq := range qms[1:] { + q = append(q, qm.Or2(sq)) + } + + return []qm.QueryMod{qm.Expr(q...)} +} diff --git a/internal/util/db/or_test.go b/internal/util/db/or_test.go new file mode 100644 index 0000000..fbff764 --- /dev/null +++ b/internal/util/db/or_test.go @@ -0,0 +1,67 @@ +package db_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOr(t *testing.T) { + age := 42 + filter := UserFilter{ + Name: Name{ + PublicName: PublicName{ + First: "Max", + }, + MiddleName: "Gustav", + Lastname: "Muster", + }, + Country: "Austria", + City: "Vienna", + Scopes: []string{"app", "user_info"}, + Age: &age, + } + + qms := []qm.QueryMod{ + qm.Where("id = ?", 123), + qm.Where("username = ?", "max.muster@example.org"), + db.WhereJSON("users", "profile", filter), + } + sql, args := buildOrQuery(t, qms) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestOrSingle(t *testing.T) { + q := qm.Where("username = ?", "max.muster@example.org") + qms := db.CombineWithOr([]qm.QueryMod{q}) + require.Len(t, qms, 1) + assert.Equal(t, q, qms[0]) +} + +func TestOrEmpty(t *testing.T) { + qms := db.CombineWithOr([]qm.QueryMod{}) + assert.Empty(t, qms) + + qms = db.CombineWithOr(nil) + assert.Empty(t, qms) +} + +func buildOrQuery(t *testing.T, qms []qm.QueryMod) (string, []interface{}) { + t.Helper() + + o := db.CombineWithOr(qms) + require.NotEmpty(t, o) + + o = append(o, qm.Select("*"), qm.From("users")) + q := models.NewQuery(o...) + + return queries.BuildQuery(q) +} diff --git a/internal/util/db/order_by.go b/internal/util/db/order_by.go new file mode 100644 index 0000000..089124e --- /dev/null +++ b/internal/util/db/order_by.go @@ -0,0 +1,32 @@ +package db + +import ( + "fmt" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +func OrderBy(orderDir types.OrderDir, path ...string) qm.QueryMod { + return qm.OrderBy(fmt.Sprintf("%s %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)))) +} + +func OrderByLower(orderDir types.OrderDir, path ...string) qm.QueryMod { + return qm.OrderBy(fmt.Sprintf("LOWER(%s) %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)))) +} + +type OrderByNulls string + +const ( + OrderByNullsFirst OrderByNulls = "FIRST" + OrderByNullsLast OrderByNulls = "LAST" +) + +func OrderByWithNulls(orderDir types.OrderDir, orderByNulls OrderByNulls, path ...string) qm.QueryMod { + return qm.OrderBy(fmt.Sprintf("%s %s NULLS %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)), orderByNulls)) +} + +func OrderByLowerWithNulls(orderDir types.OrderDir, orderByNulls OrderByNulls, path ...string) qm.QueryMod { + return qm.OrderBy(fmt.Sprintf("LOWER(%s) %s NULLS %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)), orderByNulls)) +} diff --git a/internal/util/db/order_by_test.go b/internal/util/db/order_by_test.go new file mode 100644 index 0000000..3512cd5 --- /dev/null +++ b/internal/util/db/order_by_test.go @@ -0,0 +1,68 @@ +package db_test + +import ( + "database/sql" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/test/fixtures" + swaggerTypes "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOrderBy(t *testing.T) { + test.WithTestDatabase(t, func(sqlDB *sql.DB) { + ctx := t.Context() + fix := fixtures.Fixtures() + + _, err := fix.UserRequiresConfirmation.Delete(ctx, sqlDB) + require.NoError(t, err) + + noUsername := models.User{ + Scopes: types.StringArray{"cms"}, + } + + upperUsername := models.User{ + Username: null.StringFrom("USER3@example.com"), + Scopes: types.StringArray{"cms"}, + } + + err = noUsername.Insert(ctx, sqlDB, boil.Infer()) + require.NoError(t, err) + + err = upperUsername.Insert(ctx, sqlDB, boil.Infer()) + require.NoError(t, err) + + users, err := models.Users(db.OrderBy(swaggerTypes.OrderDirAsc, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB) + require.NoError(t, err) + require.NotEmpty(t, users) + assert.Equal(t, upperUsername.ID, users[0].ID) + assert.Equal(t, upperUsername.Username, users[0].Username) + + users, err = models.Users(db.OrderByLower(swaggerTypes.OrderDirAsc, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB) + require.NoError(t, err) + require.NotEmpty(t, users) + assert.Equal(t, fix.User1.ID, users[0].ID) + assert.Equal(t, fix.User1.Username, users[0].Username) + + users, err = models.Users(db.OrderByWithNulls(swaggerTypes.OrderDirAsc, db.OrderByNullsFirst, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB) + require.NoError(t, err) + require.NotEmpty(t, users) + assert.Equal(t, noUsername.ID, users[0].ID) + assert.Equal(t, noUsername.Username, users[0].Username) + + users, err = models.Users(db.OrderByLowerWithNulls(swaggerTypes.OrderDirDesc, db.OrderByNullsLast, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB) + require.NoError(t, err) + require.NotEmpty(t, users) + assert.Equal(t, fix.UserDeactivated.ID, users[0].ID) + assert.Equal(t, fix.UserDeactivated.Username, users[0].Username) + assert.Equal(t, upperUsername.ID, users[1].ID) + assert.Equal(t, upperUsername.Username, users[1].Username) + }) +} diff --git a/internal/util/db/query_mods.go b/internal/util/db/query_mods.go new file mode 100644 index 0000000..5fe39bc --- /dev/null +++ b/internal/util/db/query_mods.go @@ -0,0 +1,17 @@ +package db + +import ( + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +// QueryMods represents a slice of query mods, implementing the `queries.Applicator` +// interface to allow for usage with eager loading methods of models. Unfortunately, +// sqlboiler does not import the (identical) type used by the library, so we have to +// declare and "implemented" it ourselves... +type QueryMods []qm.QueryMod + +// Apply applies the query mods to the query provided +func (m QueryMods) Apply(q *queries.Query) { + qm.Apply(q, m...) +} diff --git a/internal/util/db/ts_vector.go b/internal/util/db/ts_vector.go new file mode 100644 index 0000000..c735043 --- /dev/null +++ b/internal/util/db/ts_vector.go @@ -0,0 +1,28 @@ +package db + +import ( + "regexp" + "strings" +) + +var ( + tsQueryWhiteSpaceRegex = regexp.MustCompile(`\s+`) +) + +// SearchStringToTSQuery returns a TSQuery string from user input. +// The resulting query will match if every word matches a beginning of a word in the row. +// This function will trim all leading and trailing as well as consecutive whitespaces and remove all single quotes before +// transforming the input into TSQuery syntax. +// If no input was given (nil or empty string) or the value only contains invalid characters, an empty string will be returned. +func SearchStringToTSQuery(s *string) string { + if s == nil || len(*s) == 0 { + return "" + } + + v := strings.TrimSpace(strings.ReplaceAll(*s, "'", "")) + if len(v) == 0 { + return "" + } + + return "'" + tsQueryWhiteSpaceRegex.ReplaceAllString(v, "':* & '") + "':*" +} diff --git a/internal/util/db/ts_vector_test.go b/internal/util/db/ts_vector_test.go new file mode 100644 index 0000000..00cfc9e --- /dev/null +++ b/internal/util/db/ts_vector_test.go @@ -0,0 +1,41 @@ +package db_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/go-openapi/swag" + "github.com/stretchr/testify/assert" +) + +func TestSearchStringToTSQuery(t *testing.T) { + expected := "'abcde':* & '12345':* & 'xyz':*" + search := swag.String(" abcde 12345 xyz ") + out := db.SearchStringToTSQuery(search) + assert.Equal(t, expected, out) + + expected = "'abcde':*" + search = swag.String("abcde") + out = db.SearchStringToTSQuery(search) + assert.Equal(t, expected, out) + + expected = "'Hello':* & 'world':* & 'lorem':* & '12345':* & 'ipsum':* & 'abc':* & 'def':*" + search = swag.String(" Hello world lorem 12345 ipsum abc def ") + out = db.SearchStringToTSQuery(search) + assert.Equal(t, expected, out) + + expected = "" + search = nil + out = db.SearchStringToTSQuery(search) + assert.Equal(t, expected, out) + + expected = "" + search = swag.String("") + out = db.SearchStringToTSQuery(search) + assert.Equal(t, expected, out) + + expected = "" + search = swag.String(" '' ' ' ' ") + out = db.SearchStringToTSQuery(search) + assert.Equal(t, expected, out) +} diff --git a/internal/util/db/where_in.go b/internal/util/db/where_in.go new file mode 100644 index 0000000..a7e7f7a --- /dev/null +++ b/internal/util/db/where_in.go @@ -0,0 +1,25 @@ +package db + +import ( + "fmt" + + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/lib/pq" +) + +// WhereIn was a copy from sqlboiler's WHERE IN query helpers since these don't get generated for nullable columns. +// Since sqlboilers IN query helpers will set a param for earch element in the slice we reccomment using this packages IN. +func WhereIn(tableName string, columnName string, slice []string) qm.QueryMod { + return IN(fmt.Sprintf("%s.%s", tableName, columnName), slice) +} + +// IN is a replacement for sqlboilers IN query mod. sqlboilers IN will set a param for +// each element in the slice and we do not recommend to use this, because it will run into driver and +// database limits. While the sqlboiler IN fails at about ~10000 params this was tested with over 1000000. +func IN(path string, slice []string) qm.QueryMod { + return qm.Where(fmt.Sprintf("%s = any(?)", path), pq.StringArray(slice)) +} + +func NIN(path string, slice []string) qm.QueryMod { + return qm.Where(fmt.Sprintf("%s <> all(?)", path), pq.StringArray(slice)) +} diff --git a/internal/util/db/where_in_test.go b/internal/util/db/where_in_test.go new file mode 100644 index 0000000..087c8b4 --- /dev/null +++ b/internal/util/db/where_in_test.go @@ -0,0 +1,39 @@ +package db_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/models" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util/db" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" +) + +func TestWhereIn(t *testing.T) { + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.InnerJoin("users", "id", "app_user_profiles", "user_id"), + db.WhereIn("app_user_profiles", "username", []string{"max", "muster", "peter"}), + ) + + sql, args := queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} + +func TestNIN(t *testing.T) { + query := models.NewQuery( + qm.Select("*"), + qm.From("users"), + db.InnerJoin("users", "id", "app_user_profiles", "user_id"), + db.NIN("app_user_profiles.username", []string{"max", "muster", "peter"}), + ) + + sql, args := queries.BuildQuery(query) + + test.Snapshoter.Label("SQL").Save(t, sql) + test.Snapshoter.Label("Args").Save(t, args...) +} diff --git a/internal/util/env.go b/internal/util/env.go new file mode 100644 index 0000000..bf19b3b --- /dev/null +++ b/internal/util/env.go @@ -0,0 +1,223 @@ +// nolint:revive +package util + +import ( + "net/url" + "os" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/rs/zerolog/log" + "golang.org/x/text/language" +) + +const ( + mgmtSecretLen = 16 +) + +var ( + mgmtSecret string + mgmtSecretOnce sync.Once +) + +func GetEnv(key string, defaultVal string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + + return defaultVal +} + +func GetEnvEnum(key string, defaultVal string, allowedValues []string) string { + if !slices.Contains(allowedValues, defaultVal) { + log.Panic().Str("key", key).Str("value", defaultVal).Msg("Default value is not in the allowed values list.") + } + + val, ok := os.LookupEnv(key) + if !ok { + return defaultVal + } + + if !slices.Contains(allowedValues, val) { + log.Error().Str("key", key).Str("value", val).Msg("Value is not allowed. Fallback to default value.") + return defaultVal + } + + return val +} + +func GetEnvAsInt(key string, defaultVal int) int { + strVal := GetEnv(key, "") + + if val, err := strconv.Atoi(strVal); err == nil { + return val + } + + return defaultVal +} + +func GetEnvAsUint32(key string, defaultVal uint32) uint32 { + strVal := GetEnv(key, "") + + if val, err := strconv.ParseUint(strVal, 10, 32); err == nil { + return uint32(val) + } + + return defaultVal +} + +func GetEnvAsUint8(key string, defaultVal uint8) uint8 { + strVal := GetEnv(key, "") + + if val, err := strconv.ParseUint(strVal, 10, 8); err == nil { + return uint8(val) + } + + return defaultVal +} + +func GetEnvAsBool(key string, defaultVal bool) bool { + strVal := GetEnv(key, "") + + if val, err := strconv.ParseBool(strVal); err == nil { + return val + } + + return defaultVal +} + +// GetEnvAsStringArr reads ENV and returns the values split by separator. +func GetEnvAsStringArr(key string, defaultVal []string, separator ...string) []string { + strVal := GetEnv(key, "") + + if len(strVal) == 0 { + return defaultVal + } + + sep := "," + if len(separator) >= 1 { + sep = separator[0] + } + + return strings.Split(strVal, sep) +} + +// GetEnvAsStringArrTrimmed reads ENV and returns the whitespace trimmed values split by separator. +func GetEnvAsStringArrTrimmed(key string, defaultVal []string, separator ...string) []string { + slc := GetEnvAsStringArr(key, defaultVal, separator...) + + for i := range slc { + slc[i] = strings.TrimSpace(slc[i]) + } + + return slc +} + +func GetEnvAsURL(key string, defaultVal string) *url.URL { + strVal := GetEnv(key, "") + + if len(strVal) == 0 { + u, err := url.Parse(defaultVal) + if err != nil { + log.Panic().Str("key", key).Str("defaultVal", defaultVal).Err(err).Msg("Failed to parse default value for env variable as URL") + } + + return u + } + + u, err := url.Parse(strVal) + if err != nil { + log.Panic().Str("key", key).Str("strVal", strVal).Err(err).Msg("Failed to parse env variable as URL") + } + + return u +} + +func GetEnvAsLanguageTag(key string, defaultVal language.Tag) language.Tag { + strVal := GetEnv(key, "") + + if len(strVal) == 0 { + return defaultVal + } + + tag, err := language.Parse(strVal) + if err != nil { + log.Panic().Str("key", key).Str("strVal", strVal).Err(err).Msg("Failed to parse env variable as language.Tag") + } + + return tag +} + +// GetEnvAsLanguageTagArr reads ENV and returns the parsed values as []language.Tag split by separator. +func GetEnvAsLanguageTagArr(key string, defaultVal []language.Tag, separator ...string) []language.Tag { + strVal := GetEnv(key, "") + + if len(strVal) == 0 { + return defaultVal + } + + sep := "," + if len(separator) >= 1 { + sep = separator[0] + } + + splitString := strings.Split(strVal, sep) + res := []language.Tag{} + for _, s := range splitString { + tag, err := language.Parse(s) + if err != nil { + log.Panic().Str("key", key).Str("itemVal", s).Err(err).Msg("Failed to parse item value from env variable as language.Tag") + } + res = append(res, tag) + } + + return res +} + +// GetMgmtSecret returns the management secret for the app server, mainly used by health check and readiness endpoints. +// It first attempts to retrieve a value from the given environment variable and generates a cryptographically secure random string +// should no env var have been set. +// Failure to generate a random string will cause a panic as secret security cannot be guaranteed otherwise. +// Subsequent calls to GetMgmtSecret during the server's runtime will always return the same randomly generated secret for consistency. +func GetMgmtSecret(envKey string) string { + val := GetEnv(envKey, "") + + if len(val) > 0 { + return val + } + + mgmtSecretOnce.Do(func() { + var err error + mgmtSecret, err = GenerateRandomHexString(mgmtSecretLen) + if err != nil { + log.Panic().Err(err).Msg("Failed to generate random management secret") + } + + log.Warn().Str("envKey", envKey).Str("mgmtSecret", mgmtSecret).Msg("Could not retrieve management secret from env key, using randomly generated one") + }) + + return mgmtSecret +} + +func GetEnvAsLocation(key string, defaultVal string) *time.Location { + strVal := GetEnv(key, "") + + if len(strVal) == 0 { + l, err := time.LoadLocation(defaultVal) + if err != nil { + log.Panic().Str("key", key).Str("defaultVal", defaultVal).Err(err).Msg("Failed to parse default value for env variable as location") + } + + return l + } + + l, err := time.LoadLocation(strVal) + if err != nil { + log.Panic().Str("key", key).Str("strVal", strVal).Err(err).Msg("Failed to parse env variable as location") + } + + return l +} diff --git a/internal/util/env_test.go b/internal/util/env_test.go new file mode 100644 index 0000000..b4e200f --- /dev/null +++ b/internal/util/env_test.go @@ -0,0 +1,278 @@ +package util_test + +import ( + "fmt" + "net/url" + "os" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" +) + +func TestGetEnv(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_STRING" + res := util.GetEnv(testVarKey, "noVal") + assert.Equal(t, "noVal", res) + + t.Setenv(testVarKey, "string") + defer os.Unsetenv(testVarKey) + res = util.GetEnv(testVarKey, "noVal") + assert.Equal(t, "string", res) +} + +func TestGetEnvEnum(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_ENUM" + + panicFunc := func() { + _ = util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "foo"}) + } + assert.Panics(t, panicFunc) + + res := util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "smtp"}) + assert.Equal(t, "smtp", res) + + t.Setenv(testVarKey, "mock") + defer os.Unsetenv(testVarKey) + res = util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "smtp"}) + assert.Equal(t, "mock", res) + + t.Setenv(testVarKey, "foo") + res = util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "smtp"}) + assert.Equal(t, "smtp", res) +} + +func TestGetEnvAsInt(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_INT" + res := util.GetEnvAsInt(testVarKey, 1) + assert.Equal(t, 1, res) + + t.Setenv(testVarKey, "2") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsInt(testVarKey, 1) + assert.Equal(t, 2, res) + + t.Setenv(testVarKey, "3x") + res = util.GetEnvAsInt(testVarKey, 1) + assert.Equal(t, 1, res) +} + +func TestGetEnvAsUint32(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_UINT32" + res := util.GetEnvAsUint32(testVarKey, 1) + assert.Equal(t, uint32(1), res) + + t.Setenv(testVarKey, "2") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsUint32(testVarKey, 1) + assert.Equal(t, uint32(2), res) + + t.Setenv(testVarKey, "3x") + res = util.GetEnvAsUint32(testVarKey, 1) + assert.Equal(t, uint32(1), res) +} + +func TestGetEnvAsUint8(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_UINT8" + res := util.GetEnvAsUint8(testVarKey, 1) + assert.Equal(t, uint8(1), res) + + t.Setenv(testVarKey, "2") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsUint8(testVarKey, 1) + assert.Equal(t, uint8(2), res) + + t.Setenv(testVarKey, "3x") + res = util.GetEnvAsUint8(testVarKey, 1) + assert.Equal(t, uint8(1), res) +} + +func TestGetEnvAsBool(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_BOOL" + res := util.GetEnvAsBool(testVarKey, true) + assert.True(t, res) + + t.Setenv(testVarKey, "f") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsBool(testVarKey, true) + assert.False(t, res) + + t.Setenv(testVarKey, "0") + res = util.GetEnvAsBool(testVarKey, true) + assert.False(t, res) + + t.Setenv(testVarKey, "false") + res = util.GetEnvAsBool(testVarKey, true) + assert.False(t, res) + + t.Setenv(testVarKey, "3x") + res = util.GetEnvAsBool(testVarKey, true) + assert.True(t, res) +} + +func TestGetEnvAsURL(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_URL" + testURL, err := url.Parse("https://allaboutapps.at/") + require.NoError(t, err) + + panicFunc := func() { + _ = util.GetEnvAsURL(testVarKey, "%") + } + assert.Panics(t, panicFunc) + + res := util.GetEnvAsURL(testVarKey, "https://allaboutapps.at/") + assert.Equal(t, *testURL, *res) + + t.Setenv(testVarKey, "https://allaboutapps.at/") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsURL(testVarKey, "foo") + assert.Equal(t, *testURL, *res) + + t.Setenv(testVarKey, "%") + panicFunc = func() { + _ = util.GetEnvAsURL(testVarKey, "https://allaboutapps.at/") + } + assert.Panics(t, panicFunc) +} + +func TestGetEnvAsStringArr(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_STRING_ARR" + testVal := []string{"a", "b", "c"} + res := util.GetEnvAsStringArr(testVarKey, testVal) + assert.Equal(t, testVal, res) + + t.Setenv(testVarKey, "1,2") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsStringArr(testVarKey, testVal) + assert.Equal(t, []string{"1", "2"}, res) + + t.Setenv(testVarKey, "") + res = util.GetEnvAsStringArr(testVarKey, testVal) + assert.Equal(t, testVal, res) + + t.Setenv(testVarKey, "a, b, c") + res = util.GetEnvAsStringArr(testVarKey, testVal) + assert.Equal(t, []string{"a", " b", " c"}, res) + + t.Setenv(testVarKey, "a|b|c") + res = util.GetEnvAsStringArr(testVarKey, testVal, "|") + assert.Equal(t, []string{"a", "b", "c"}, res) + + t.Setenv(testVarKey, "a,b,c") + res = util.GetEnvAsStringArr(testVarKey, testVal, "|") + assert.Equal(t, []string{"a,b,c"}, res) + + t.Setenv(testVarKey, "a||b||c") + res = util.GetEnvAsStringArr(testVarKey, testVal, "||") + assert.Equal(t, []string{"a", "b", "c"}, res) +} + +func TestGetEnvAsStringArrTrimmed(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_STRING_ARR_TRIMMED" + testVal := []string{"a", "b", "c"} + + t.Setenv(testVarKey, "a, b, c") + defer os.Unsetenv(testVarKey) + res := util.GetEnvAsStringArrTrimmed(testVarKey, testVal) + assert.Equal(t, []string{"a", "b", "c"}, res) + + t.Setenv(testVarKey, "a, b,c ") + res = util.GetEnvAsStringArrTrimmed(testVarKey, testVal) + assert.Equal(t, []string{"a", "b", "c"}, res) + + t.Setenv(testVarKey, " a || b || c ") + res = util.GetEnvAsStringArrTrimmed(testVarKey, testVal, "||") + assert.Equal(t, []string{"a", "b", "c"}, res) +} + +func TestGetMgmtSecret(t *testing.T) { + rs, err := util.GenerateRandomHexString(8) + require.NoError(t, err) + + key := fmt.Sprintf("WE_WILL_NEVER_USE_THIS_MGMT_SECRET_%s", rs) + expectedVal := fmt.Sprintf("SUPER_SECRET_%s", rs) + + t.Setenv(key, expectedVal) + + for i := 0; i < 5; i++ { + val := util.GetMgmtSecret(key) + assert.Equal(t, expectedVal, val) + } +} + +func TestGetEnvAsLanguageTag(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_LANG" + res := util.GetEnvAsLanguageTag(testVarKey, language.German) + assert.Equal(t, language.German, res) + + t.Setenv(testVarKey, "en") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsLanguageTag(testVarKey, language.German) + assert.Equal(t, language.English, res) +} + +func TestGetEnvAsLanguageTagArr(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_LANG_ARR" + testVal := []language.Tag{language.German, language.English, language.Spanish} + res := util.GetEnvAsLanguageTagArr(testVarKey, testVal) + assert.Equal(t, testVal, res) + + t.Setenv(testVarKey, "de,en") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsLanguageTagArr(testVarKey, testVal) + assert.Equal(t, []language.Tag{language.German, language.English}, res) + + t.Setenv(testVarKey, "") + res = util.GetEnvAsLanguageTagArr(testVarKey, testVal) + assert.Equal(t, testVal, res) + + t.Setenv(testVarKey, "en|es") + res = util.GetEnvAsLanguageTagArr(testVarKey, testVal, "|") + assert.Equal(t, []language.Tag{language.English, language.Spanish}, res) + + t.Setenv(testVarKey, "en||es") + res = util.GetEnvAsLanguageTagArr(testVarKey, testVal, "||") + assert.Equal(t, []language.Tag{language.English, language.Spanish}, res) +} + +func TestGetMgmtSecretRandom(t *testing.T) { + expectedVal := util.GetMgmtSecret("DOES_NOT_EXIST_MGMT_SECRET") + require.NotEmpty(t, expectedVal) + + for i := 0; i < 5; i++ { + val := util.GetMgmtSecret("DOES_NOT_EXIST_MGMT_SECRET") + assert.Equal(t, expectedVal, val) + } +} + +func TestGetEnvAsLocation(t *testing.T) { + testVarKey := "TEST_ONLY_FOR_UNIT_TEST_LOCATION" + res := util.GetEnvAsLocation(testVarKey, "UTC") + assert.Equal(t, time.UTC, res) + + t.Setenv(testVarKey, "Local") + defer os.Unsetenv(testVarKey) + res = util.GetEnvAsLocation(testVarKey, "UTC") + assert.Equal(t, time.Local, res) + + vienna, err := time.LoadLocation("Europe/Vienna") + require.NoError(t, err) + t.Setenv(testVarKey, "Europe/Vienna") + res = util.GetEnvAsLocation(testVarKey, "UTC") + assert.Equal(t, vienna, res) + + panicFunc := func() { + t.Setenv(testVarKey, "") + _ = util.GetEnvAsLocation(testVarKey, "not-valud") + } + assert.Panics(t, panicFunc) + + panicFunc = func() { + t.Setenv(testVarKey, "not-valid") + _ = util.GetEnvAsLocation(testVarKey, "UTC") + } + assert.Panics(t, panicFunc) +} diff --git a/internal/util/fs.go b/internal/util/fs.go new file mode 100644 index 0000000..f7ce6c4 --- /dev/null +++ b/internal/util/fs.go @@ -0,0 +1,39 @@ +// nolint:revive +package util + +import ( + "fmt" + "os" + "time" +) + +// TouchFile creates an empty file if the file doesn’t already exist. +// If the file already exists then TouchFile updates the modified time of the file. +// Returns the modification time of the created / updated file. +func TouchFile(absolutePathToFile string) (time.Time, error) { + _, err := os.Stat(absolutePathToFile) + + if os.IsNotExist(err) { + file, err := os.Create(absolutePathToFile) + if err != nil { + return time.Time{}, fmt.Errorf("failed to create file: %w", err) + } + + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return time.Time{}, fmt.Errorf("failed to stat file: %w", err) + } + + return stat.ModTime(), nil + } + + currentTime := time.Now().Local() + err = os.Chtimes(absolutePathToFile, currentTime, currentTime) + if err != nil { + return time.Time{}, fmt.Errorf("failed to change file time: %w", err) + } + + return currentTime, nil +} diff --git a/internal/util/fs_test.go b/internal/util/fs_test.go new file mode 100644 index 0000000..9c92653 --- /dev/null +++ b/internal/util/fs_test.go @@ -0,0 +1,28 @@ +package util_test + +import ( + "os" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTouchfile(t *testing.T) { + err := os.Remove("/tmp/.touchfile-test") + if err != nil { + require.Truef(t, os.IsNotExist(err), "Only permitting os.IsNotExist(err) as file may not preexistant on test start, but is: %v", err) + } + + ts1, err := util.TouchFile("/tmp/.touchfile-test") + require.NoError(t, err) + + ts2, err := util.TouchFile("/tmp/.touchfile-test") + require.NoError(t, err) + require.NotEqual(t, ts1.UnixNano(), ts2.UnixNano()) + + zeroTime, err := util.TouchFile("/this/path/does/not/exist/.touchfile-test") + require.Error(t, err) + assert.True(t, zeroTime.IsZero(), "time.Time on error should be zero time") +} diff --git a/internal/util/get_project_root_dir.go b/internal/util/get_project_root_dir.go new file mode 100644 index 0000000..5f30723 --- /dev/null +++ b/internal/util/get_project_root_dir.go @@ -0,0 +1,36 @@ +//go:build !scripts + +// nolint:revive +package util + +import ( + "os" + "path/filepath" + "sync" + + "github.com/rs/zerolog/log" +) + +var ( + projectRootDir string + dirOnce sync.Once +) + +// GetProjectRootDir returns the path as string to the project_root for a **running application**. +// Note: This function should not be used for generation targets (go generate, make go-generate). +// Thus it's explicitly excluded from the build tag scripts, see instead: +// * /scripts/get_project_root_dir.go +// * ./get_project_root_dir_scripts.go (delegates to above) +// https://stackoverflow.com/questions/43215655/building-multiple-binaries-using-different-packages-and-build-tags +func GetProjectRootDir() string { + dirOnce.Do(func() { + ex, err := os.Executable() + if err != nil { + log.Panic().Err(err).Msg("Failed to get executable path while retrieving project root directory") + } + + projectRootDir = GetEnv("PROJECT_ROOT_DIR", filepath.Dir(ex)) + }) + + return projectRootDir +} diff --git a/internal/util/get_project_root_dir_scripts.go b/internal/util/get_project_root_dir_scripts.go new file mode 100644 index 0000000..f359db6 --- /dev/null +++ b/internal/util/get_project_root_dir_scripts.go @@ -0,0 +1,21 @@ +//go:build scripts + +// nolint:revive +package util + +import "os" + +// Note that VSCode/gopls currently spawns a "No packages found for open file: [...]" here. +// This is expected and will go away with gopls v1.0, see https://github.com/golang/go/issues/29202 + +// GetProjectRootDir returns the path as string to the project_root while **scripts generation**. +// Note: This function replaces the original util.GetProjectRootDir when go runs with the "script" build tag. +// https://stackoverflow.com/questions/43215655/building-multiple-binaries-using-different-packages-and-build-tags +// Should be in sync with "scripts/internal/util/get_project_root_dir.go" +func GetProjectRootDir() string { + if val, ok := os.LookupEnv("PROJECT_ROOT_DIR"); ok { + return val + } + + return "/app" +} diff --git a/internal/util/hashing/argon2.go b/internal/util/hashing/argon2.go new file mode 100644 index 0000000..2f7f155 --- /dev/null +++ b/internal/util/hashing/argon2.go @@ -0,0 +1,114 @@ +package hashing + +import ( + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "math" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Inspired by: https://github.com/alexedwards/argon2id @ 2020-04-22T14:13:23ZZ + +const ( + // Argon2HashID represents the hash ID set in the (pseudo) modular crypt format used to store the hashed password and params in a single string. + Argon2HashID = "argon2id" +) + +var ( + // ErrInvalidArgon2Hash indicates the argon2id hash was malformed and could not be decoded. + ErrInvalidArgon2Hash = errors.New("invalid argon2id hash") + // ErrIncompatibleArgon2Version indicates the argon2id hash provided was generated with a different, incompatible argon2 version. + ErrIncompatibleArgon2Version = errors.New("incompatible argon2 version") +) + +func HashPassword(password string, params *Argon2Params) (string, error) { + salt, err := generateSalt(params.SaltLength) + if err != nil { + return "", err + } + + key := argon2.IDKey([]byte(password), salt, params.Time, params.Memory, params.Threads, params.KeyLength) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Key := base64.RawStdEncoding.EncodeToString(key) + + return fmt.Sprintf("$%s$v=%d$m=%d,t=%d,p=%d$%s$%s", Argon2HashID, argon2.Version, params.Memory, params.Time, params.Threads, b64Salt, b64Key), nil +} + +func ComparePasswordAndHash(password string, hash string) (bool, error) { + params, salt, key, err := decodeArgon2Hash(hash) + if err != nil { + return false, err + } + + pKey := argon2.IDKey([]byte(password), salt, params.Time, params.Memory, params.Threads, params.KeyLength) + + keyLen := len(key) + pKeyLen := len(pKey) + + if keyLen > math.MaxInt32 || pKeyLen > math.MaxInt32 { + return false, ErrInvalidArgon2Hash + } + + if subtle.ConstantTimeEq(int32(keyLen), int32(pKeyLen)) == 0 { + return false, nil + } + + if subtle.ConstantTimeCompare(key, pKey) == 0 { + return false, nil + } + + return true, nil +} + +const ( + argon2HashParts = 6 +) + +func decodeArgon2Hash(hash string) (*Argon2Params, []byte, []byte, error) { + vals := strings.Split(hash, "$") // splits into array of 6 values, with val[0] being empty --> length/indicies "offset" by one + if len(vals) != argon2HashParts { + return nil, nil, nil, ErrInvalidArgon2Hash + } + if vals[1] != Argon2HashID { + return nil, nil, nil, ErrInvalidArgon2Hash + } + + var version int + _, err := fmt.Sscanf(vals[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, ErrIncompatibleArgon2Version + } + if version != argon2.Version { + return nil, nil, nil, ErrIncompatibleArgon2Version + } + + params := &Argon2Params{} + _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", ¶ms.Memory, ¶ms.Time, ¶ms.Threads) + if err != nil { + return nil, nil, nil, ErrInvalidArgon2Hash + } + + salt, err := base64.RawStdEncoding.DecodeString(vals[4]) + if err != nil { + return nil, nil, nil, ErrInvalidArgon2Hash + } + + key, err := base64.RawStdEncoding.DecodeString(vals[5]) + if err != nil { + return nil, nil, nil, ErrInvalidArgon2Hash + } + + keyLength := len(key) + if keyLength > math.MaxInt32 { + return nil, nil, nil, ErrInvalidArgon2Hash + } + + params.KeyLength = uint32(keyLength) + + return params, salt, key, nil +} diff --git a/internal/util/hashing/argon2_params.go b/internal/util/hashing/argon2_params.go new file mode 100644 index 0000000..c35b231 --- /dev/null +++ b/internal/util/hashing/argon2_params.go @@ -0,0 +1,39 @@ +package hashing + +import "allaboutapps.dev/aw/go-starter/internal/util" + +var ( + // DefaultArgon2Params represents Argon2ID parameter recommendations in accordance with: + // https://pkg.go.dev/golang.org/x/crypto@v0.0.0-20200420201142-3c4aac89819a/argon2?tab=doc#IDKey @ 2020-04-22T11:23:38Z + DefaultArgon2Params = &Argon2Params{ + Time: 1, // 1 second + //nolint:mnd + Memory: 64 * 1024, // ~64MB memory costs + //nolint:mnd + Threads: 4, // 4 threads + //nolint:mnd + KeyLength: 32, // 256 bit key length + //nolint:mnd + SaltLength: 16, // 126 bit salt length + } +) + +type Argon2Params struct { + Time uint32 + Memory uint32 + Threads uint8 + KeyLength uint32 + SaltLength uint32 +} + +func DefaultArgon2ParamsFromEnv() *Argon2Params { + params := &Argon2Params{ + Time: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_TIME", DefaultArgon2Params.Time), + Memory: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_MEMORY", DefaultArgon2Params.Memory), + Threads: util.GetEnvAsUint8("AUTH_HASHING_ARGON2_THREADS", DefaultArgon2Params.Threads), + KeyLength: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_KEY_LENGTH", DefaultArgon2Params.KeyLength), + SaltLength: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_SALT_LENGTH", DefaultArgon2Params.SaltLength), + } + + return params +} diff --git a/internal/util/hashing/argon2_test.go b/internal/util/hashing/argon2_test.go new file mode 100644 index 0000000..0023a20 --- /dev/null +++ b/internal/util/hashing/argon2_test.go @@ -0,0 +1,106 @@ +package hashing_test + +import ( + "regexp" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util/hashing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashPassword(t *testing.T) { + hashRegex, err := regexp.Compile(`^\$argon2id\$v=19\$m=65536,t=1,p=4\$[A-Za-z0-9+/]{22}\$[A-Za-z0-9+/]{43}$`) + require.NoError(t, err, "failed to compile hash regex") + + hash1, err := hashing.HashPassword("t3stp4ssw0rd", hashing.DefaultArgon2Params) + require.NoError(t, err, "failed to hash password") + + assert.Truef(t, hashRegex.MatchString(hash1), "hash %q is not formatted properly", hash1) + + hash2, err := hashing.HashPassword("t3stp4ssw0rd", hashing.DefaultArgon2Params) + require.NoError(t, err, "failed to hash password") + + assert.Truef(t, hashRegex.MatchString(hash2), "hash %q is not formatted properly", hash2) + + assert.NotEqualf(t, hash1, hash2, "hashes %q and %q are not unique", hash1, hash2) +} + +func BenchmarkHashPassword(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := hashing.HashPassword("t3stp4ssw0rd", hashing.DefaultArgon2Params) + if err != nil { + b.Errorf("failed to hash password #%d: %v", i, err) + } + } +} + +const ( + hash = "$argon2id$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk" +) + +func TestComparePasswordAndHash(t *testing.T) { + match, err := hashing.ComparePasswordAndHash("t3stp4ssw0rd", hash) + require.NoError(t, err) + assert.True(t, match) + + match, err = hashing.ComparePasswordAndHash("wr0ngt3stp4ssw0rd", hash) + require.NoError(t, err) + assert.False(t, match) +} + +func BenchmarkComparePasswordAndHash(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := hashing.ComparePasswordAndHash("t3stp4ssw0rd", hash) + if err != nil { + b.Errorf("failed to compare password and hash #%d: %v", i, err) + } + } +} + +func BenchmarkCompareWrongPasswordAndHash(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := hashing.ComparePasswordAndHash("wr0ngt3stp4ssw0rd", hash) + if err != nil { + b.Errorf("failed to compare wrong password and hash #%d: %v", i, err) + } + } +} + +func TestComparePasswordAndInvalidHash(t *testing.T) { + _, err := hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2i$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=20$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrIncompatibleArgon2Version, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=a$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrIncompatibleArgon2Version, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=a,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=a,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=a$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=4$ä$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) + + _, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$ä") + require.Error(t, err) + assert.Equal(t, hashing.ErrInvalidArgon2Hash, err) +} diff --git a/internal/util/hashing/util.go b/internal/util/hashing/util.go new file mode 100644 index 0000000..7e37998 --- /dev/null +++ b/internal/util/hashing/util.go @@ -0,0 +1,17 @@ +package hashing + +import ( + "crypto/rand" + "fmt" +) + +func generateSalt(n uint32) ([]byte, error) { + result := make([]byte, n) + + _, err := rand.Read(result) + if err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + + return result, nil +} diff --git a/internal/util/http.go b/internal/util/http.go new file mode 100644 index 0000000..c24d0a3 --- /dev/null +++ b/internal/util/http.go @@ -0,0 +1,374 @@ +// nolint:revive +package util + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/gabriel-vasile/mimetype" + oerrors "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +const ( + HTTPHeaderCacheControl = "Cache-Control" +) + +// BindAndValidateBody binds the request, parsing **only** its body (depending on the `Content-Type` request header) and performs validation +// as enforced by the Swagger schema associated with the provided type. +// +// Note: In contrast to BindAndValidate, this method does not restore the body after binding (it's considered consumed). +// Thus use BindAndValidateBody only once per request! +// +// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail. +func BindAndValidateBody(c echo.Context, v runtime.Validatable) error { + binder, ok := c.Echo().Binder.(*echo.DefaultBinder) + if !ok { + return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder) + } + + if err := binder.BindBody(c, v); err != nil { + return fmt.Errorf("failed to bind body: %w", err) + } + + return validatePayload(c, v) +} + +// BindAndValidatePathAndQueryParams binds the request, parsing **only** its path **and** query params and performs validation +// as enforced by the Swagger schema associated with the provided type. +// +// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail. +func BindAndValidatePathAndQueryParams(c echo.Context, v runtime.Validatable) error { + binder, ok := c.Echo().Binder.(*echo.DefaultBinder) + if !ok { + return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder) + } + + if err := binder.BindPathParams(c, v); err != nil { + return fmt.Errorf("failed to bind path params: %w", err) + } + + if err := binder.BindQueryParams(c, v); err != nil { + return fmt.Errorf("failed to bind query params: %w", err) + } + + return validatePayload(c, v) +} + +// BindAndValidatePathParams binds the request, parsing **only** its path params and performs validation +// as enforced by the Swagger schema associated with the provided type. +// +// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail. +func BindAndValidatePathParams(c echo.Context, v runtime.Validatable) error { + binder, ok := c.Echo().Binder.(*echo.DefaultBinder) + if !ok { + return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder) + } + + if err := binder.BindPathParams(c, v); err != nil { + return fmt.Errorf("failed to bind path params: %w", err) + } + + return validatePayload(c, v) +} + +// BindAndValidateQueryParams binds the request, parsing **only** its query params and performs validation +// as enforced by the Swagger schema associated with the provided type. +// +// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail. +func BindAndValidateQueryParams(c echo.Context, v runtime.Validatable) error { + binder, ok := c.Echo().Binder.(*echo.DefaultBinder) + if !ok { + return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder) + } + + if err := binder.BindQueryParams(c, v); err != nil { + return fmt.Errorf("failed to bind query params: %w", err) + } + + return validatePayload(c, v) +} + +// BindAndValidate binds the request, parsing path+query+body and validating these structs. +// +// Deprecated: Use our dedicated BindAndValidate* mappers instead: +// +// BindAndValidateBody(c echo.Context, v runtime.Validatable) error // preferred +// BindAndValidatePathAndQueryParams(c echo.Context, v runtime.Validatable) error // preferred +// BindAndValidatePathParams(c echo.Context, v runtime.Validatable) error // rare usecases +// BindAndValidateQueryParams(c echo.Context, v runtime.Validatable) error // rare usecases +// +// BindAndValidate works like Echo =v4.2.0 DefaultBinder now supports binding query, path params and body to their **own** structs natively. +// Thus, you areencouraged to use our new dedicated BindAndValidate* mappers, which are relevant for the structs goswagger +// autogenerates for you. +// +// Original: Parses body (depending on the `Content-Type` request header) and performs payload validation as enforced by +// the Swagger schema associated with the provided type. In addition to binding the body, BindAndValidate also assigns query +// and URL parameters (if any) to a struct and perform validations on those. +// +// Providing more than one struct allows for binding payload and parameters simultaneously since echo and goswagger expect data +// to be structured differently. If you do not require parsing of both body and params, additional structs can be omitted. +// +// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail. +func BindAndValidate(c echo.Context, v runtime.Validatable, validatables ...runtime.Validatable) error { + // TODO error handling for all occurrences of Bind() due to JSON unmarshal type mismatches + if len(validatables) == 0 { + if err := defaultEchoBindAll(c, v); err != nil { + return err + } + + return validatePayload(c, v) + } + + var reqBody []byte + var err error + if c.Request().Body != nil { + reqBody, err = io.ReadAll(c.Request().Body) + if err != nil { + return fmt.Errorf("failed to read request body: %w", err) + } + } + + if err = restoreBindAndValidate(c, reqBody, v); err != nil { + return fmt.Errorf("failed to restore bind and validate: %w", err) + } + + for _, vv := range validatables { + if err = restoreBindAndValidate(c, reqBody, vv); err != nil { + return fmt.Errorf("failed to restore bind and validate: %w", err) + } + } + + return nil +} + +// ValidateAndReturn returns the provided data as a JSON response with the given HTTP status code after performing payload +// validation as enforced by the Swagger schema associated with the provided type. +// `v` must implement `github.com/go-openapi/runtime.Validatable` in order to perform validations, otherwise an internal server error is thrown. +// Returns an error that can directly be returned from an echo handler and sent to the client should sending or validating fail. +func ValidateAndReturn(c echo.Context, code int, v runtime.Validatable) error { + if err := validatePayload(c, v); err != nil { + return err + } + + if err := c.JSON(code, v); err != nil { + return fmt.Errorf("failed to return JSON: %w", err) + } + + return nil +} + +func ParseFileUpload(c echo.Context, formNameFile string, allowedMIMETypes []string) (*multipart.FileHeader, multipart.File, *mimetype.MIME, error) { + log := LogFromEchoContext(c) + + fileHeader, err := c.FormFile(formNameFile) + if err != nil { + log.Debug().Err(err).Msg("Failed to get form file") + return nil, nil, nil, fmt.Errorf("failed to get form file: %w", err) + } + + file, err := fileHeader.Open() + if err != nil { + log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("Failed to open uploaded file") + return nil, nil, nil, fmt.Errorf("failed to open uploaded file: %w", err) + } + + if fileHeader.Size < 1 { + log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("File size can't be 0") + return nil, nil, nil, httperrors.ErrBadRequestZeroFileSize + } + + mime, err := mimetype.DetectReader(file) + if err != nil { + log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("Failed to detect MIME type of uploaded file") + file.Close() + return nil, nil, nil, fmt.Errorf("failed to detect MIME type of uploaded file: %w", err) + } + + // ! Important: we *MUST* reset the reader back to 0, since `minetype.DetectReader` reads the beginning of the + // ! file in order to detect it's MIME type. Continuing to use the reader without resetting it results in a + // ! corrupted file unable to be processed or opened otherwise. + if _, err = file.Seek(0, io.SeekStart); err != nil { + log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("Failed to reset reader of uploaded file to start") + file.Close() + return nil, nil, nil, fmt.Errorf("failed to reset reader of uploaded file to start: %w", err) + } + + allowed := false + for _, allowedType := range allowedMIMETypes { + if mime.Is(allowedType) { + log.Debug(). + Str("mimeType", mime.String()). + Str("mimeTypeFileExtension", mime.Extension()). + Str("filename", fileHeader.Filename). + Int64("fileSize", fileHeader.Size). + Str("allowedMIMEType", allowedType). + Msg("MIME type of uploaded file is allowed, processing") + + allowed = true + + break + } + } + + if !allowed { + log.Debug(). + Str("mimeType", mime.String()). + Str("mimeTypeFileExtension", mime.Extension()). + Str("filename", fileHeader.Filename). + Int64("fileSize", fileHeader.Size). + Msg("MIME type of uploaded file is not allowed, rejecting") + file.Close() + + return nil, nil, nil, echo.ErrUnsupportedMediaType + } + + return fileHeader, file, mime, nil +} + +func StreamFile(c echo.Context, code int, mediaType string, fileName string, r io.ReadCloser) error { + formattedMediaType := mime.FormatMediaType("attachment", + map[string]string{ + "filename": fileName, + }, + ) + + c.Response().Header().Set(echo.HeaderContentDisposition, formattedMediaType) + SetOrAppendHeader(c.Response().Header(), echo.HeaderAccessControlExposeHeaders, echo.HeaderContentDisposition) + + defer r.Close() + + if err := c.Stream(code, mediaType, r); err != nil { + return fmt.Errorf("failed to stream file: %w", err) + } + + return nil +} + +func SetOrAppendHeader(header http.Header, key string, values ...string) { + headerSet := header.Get(key) + headerValue := strings.Join(values, ", ") + if headerSet == "" { + header.Add(key, headerValue) + } else { + header.Set(key, strings.Join([]string{headerSet, headerValue}, ", ")) + } +} + +func restoreBindAndValidate(c echo.Context, reqBody []byte, v runtime.Validatable) error { + if reqBody != nil { + c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody)) + } + + if err := defaultEchoBindAll(c, v); err != nil { + return err + } + + return validatePayload(c, v) +} + +func validatePayload(c echo.Context, v runtime.Validatable) error { + if err := v.Validate(strfmt.Default); err != nil { + var compositeError *oerrors.CompositeError + if errors.As(err, &compositeError) { + LogFromEchoContext(c).Debug().Errs("validation_errors", compositeError.Errors).Msg("Payload did match schema, returning HTTP validation error") + + valErrs := formatValidationErrors(c.Request().Context(), compositeError) + + return httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs) + } + + var validationError *oerrors.Validation + if errors.As(err, &validationError) { + LogFromEchoContext(c).Debug().AnErr("validation_error", validationError).Msg("Payload did match schema, returning HTTP validation error") + + valErrs := []*types.HTTPValidationErrorDetail{ + { + Key: &validationError.Name, + In: &validationError.In, + Error: swag.String(validationError.Error()), + }, + } + + return httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs) + } + + LogFromEchoContext(c).Error().Err(err).Msg("Failed to validate payload, returning generic HTTP error") + + return fmt.Errorf("failed to validate payload: %w", err) + } + + return nil +} + +// Bind it all +// Restores echo query binding pre 4.2.0 handling +// Newer echo versions no longer automatically bind query params to tagged :query struct-fields unless its a GET or DELETE request +// Workaround, depends on the internal echo.DefaultBinder methods. +// +// TODO: Eventually move to a customly implemented Binder. +// Hopefully BindPathParams, BindQueryParams and BindBody stay provided in the future. +// +// This upstream security fix does not directly affect us, as our goswagger generated params/query structs +// and body structs are separated from each other and cannot collide/overwrite props. +// https://github.com/labstack/echo/commit/4d626c210d3946814a30d545adf9b8f2296686a7#diff-aade326d3512b5a2ada6faa791ddec468f2a0adedb352339c9e314e74c8949d2 +func defaultEchoBindAll(c echo.Context, v runtime.Validatable) error { + binder, ok := c.Echo().Binder.(*echo.DefaultBinder) + if !ok { + return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder) + } + + if err := binder.BindPathParams(c, v); err != nil { + return fmt.Errorf("failed to bind path params: %w", err) + } + if err := binder.BindQueryParams(c, v); err != nil { + return fmt.Errorf("failed to bind query params: %w", err) + } + + if err := binder.BindBody(c, v); err != nil { + return fmt.Errorf("failed to bind body: %w", err) + } + + return nil +} + +func formatValidationErrors(ctx context.Context, compositeError *oerrors.CompositeError) []*types.HTTPValidationErrorDetail { + valErrs := make([]*types.HTTPValidationErrorDetail, 0, len(compositeError.Errors)) + for _, err := range compositeError.Errors { + var compositeError *oerrors.CompositeError + if errors.As(err, &compositeError) { + valErrs = append(valErrs, formatValidationErrors(ctx, compositeError)...) + continue + } + + var validationError *oerrors.Validation + if errors.As(err, &validationError) { + valErrs = append(valErrs, &types.HTTPValidationErrorDetail{ + Key: &validationError.Name, + In: &validationError.In, + Error: swag.String(validationError.Error()), + }) + + continue + } + + LogFromContext(ctx).Warn().Err(err).Str("err_type", fmt.Sprintf("%T", err)).Msg("Received unknown error type while validating payload, skipping") + } + + return valErrs +} diff --git a/internal/util/http_test.go b/internal/util/http_test.go new file mode 100644 index 0000000..ba37e6b --- /dev/null +++ b/internal/util/http_test.go @@ -0,0 +1,233 @@ +package util_test + +import ( + "bytes" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/httperrors" + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/types" + "allaboutapps.dev/aw/go-starter/internal/types/auth" + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/gabriel-vasile/mimetype" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBindAndValidateSuccess(t *testing.T) { + e := echo.New() + //nolint:gosec + testToken := "a546daf5-c845-46a7-8fa6-3d94ae7e1424" + testResponse := &types.PostLoginResponse{ + AccessToken: conv.UUID4(strfmt.UUID4("afbcbc30-4794-48bd-93f1-08373a031fe3")), + RefreshToken: conv.UUID4(strfmt.UUID4("1dd1228c-fa9a-4755-b995-30e24dd6247d")), + ExpiresIn: swag.Int64(3600), + TokenType: swag.String("Bearer"), + } + + e.POST("/", func(c echo.Context) error { + testParam1 := auth.NewGetUserInfoRouteParams() + testParam2 := auth.NewPostForgotPasswordRouteParams() + var body types.PostRefreshPayload + + err := util.BindAndValidate(c, &body, &testParam1, &testParam2) + require.NoError(t, err) + assert.NotEmpty(t, body) + assert.Equal(t, strfmt.UUID4(testToken), *body.RefreshToken) + + return util.ValidateAndReturn(c, 200, testResponse) + }) + testBody := test.GenericPayload{ + "refresh_token": testToken, + } + + s := &api.Server{ + Echo: e, + } + + res := test.PerformRequest(t, s, "POST", "/?test=true", testBody, nil) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.PostLoginResponse + test.ParseResponseAndValidate(t, res, &response) + + assert.Equal(t, *testResponse, response) +} + +func TestBindAndValidateBadRequest(t *testing.T) { + e := echo.New() + testToken := "foo" + + e.POST("/", func(c echo.Context) error { + var body types.PostRefreshPayload + + err := util.BindAndValidateBody(c, &body) + require.Error(t, err) + + return nil + }) + testBody := test.GenericPayload{ + "refresh_token": testToken, + } + + s := &api.Server{ + Echo: e, + } + + _ = test.PerformRequest(t, s, "POST", "/?test=true", testBody, nil) +} + +func TestParseFileUplaod(t *testing.T) { + originalDocumentPath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg") + body, contentType := prepareFileUpload(t, originalDocumentPath) + + e := echo.New() + e.POST("/", func(c echo.Context) error { + fh, file, mime, err := util.ParseFileUpload(c, "file", []string{"image/jpeg"}) + require.NoError(t, err) + assert.True(t, mime.Is("image/jpeg")) + assert.NotEmpty(t, fh) + assert.NotEmpty(t, file) + + return c.NoContent(204) + }) + + s := &api.Server{ + Echo: e, + } + + headers := http.Header{} + headers.Set(echo.HeaderContentType, contentType) + + res := test.PerformRequestWithRawBody(t, s, "POST", "/", body, headers, nil) + + require.Equal(t, http.StatusNoContent, res.Result().StatusCode) +} + +func TestParseFileUplaodUnsupported(t *testing.T) { + originalDocumentPath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg") + body, contentType := prepareFileUpload(t, originalDocumentPath) + + e := echo.New() + e.POST("/", func(c echo.Context) error { + fh, file, mime, err := util.ParseFileUpload(c, "file", []string{"image/png"}) + assert.Nil(t, fh) + assert.Nil(t, file) + assert.Nil(t, mime) + if err != nil { + return err + } + + return c.NoContent(204) + }) + + s := &api.Server{ + Echo: e, + } + + headers := http.Header{} + headers.Set(echo.HeaderContentType, contentType) + + res := test.PerformRequestWithRawBody(t, s, "POST", "/", body, headers, nil) + require.Equal(t, http.StatusUnsupportedMediaType, res.Result().StatusCode) +} + +func TestParseFileUplaodEmpty(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + _, err := writer.CreateFormFile("file", filepath.Base("example.txt")) + require.NoError(t, err) + + err = writer.Close() + require.NoError(t, err) + + e := echo.New() + e.POST("/", func(c echo.Context) error { + fh, file, mime, err := util.ParseFileUpload(c, "file", []string{"text/plain"}) + assert.Nil(t, fh) + assert.Nil(t, file) + assert.Nil(t, mime) + assert.Equal(t, httperrors.ErrBadRequestZeroFileSize, err) + if err != nil { + return fmt.Errorf("failed to parse file upload: %w", err) + } + + return c.NoContent(204) + }) + + s := &api.Server{ + Echo: e, + } + + headers := http.Header{} + headers.Set(echo.HeaderContentType, writer.FormDataContentType()) + + test.PerformRequestWithRawBody(t, s, "POST", "/", &body, headers, nil) +} + +func prepareFileUpload(t *testing.T, filePath string) (*bytes.Buffer, string) { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + src, err := os.Open(filePath) + require.NoError(t, err) + defer src.Close() + + dst, err := writer.CreateFormFile("file", filepath.Base(src.Name())) + require.NoError(t, err) + + _, err = io.Copy(dst, src) + require.NoError(t, err) + + err = writer.Close() + require.NoError(t, err) + + return &body, writer.FormDataContentType() +} + +func TestStreamFile(t *testing.T) { + filename := "file_with_special_characters_🎉_س_.vcf" + + e := echo.New() + e.GET("/files", func(c echo.Context) error { + path := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg") + + mediaType, err := mimetype.DetectFile(path) + require.NoError(t, err) + + file, err := os.Open(path) + require.NoError(t, err) + + return util.StreamFile(c, http.StatusOK, mediaType.String(), filename, file) + }) + + s := &api.Server{Echo: e} + + res := test.PerformRequest(t, s, "GET", "/files", nil, nil) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + mediaType, params, err := mime.ParseMediaType(res.Header().Get(echo.HeaderContentDisposition)) + require.NoError(t, err) + + assert.Equal(t, "attachment", mediaType) + assert.Equal(t, filename, params["filename"]) + + contentType := res.Header().Get(echo.HeaderContentType) + assert.Equal(t, "image/jpeg", contentType) +} diff --git a/internal/util/int.go b/internal/util/int.go new file mode 100644 index 0000000..29c9544 --- /dev/null +++ b/internal/util/int.go @@ -0,0 +1,32 @@ +// nolint:revive +package util + +import ( + "math" + + "github.com/go-openapi/swag" +) + +func IntPtrToInt64Ptr(num *int) *int64 { + if num == nil { + return nil + } + + return swag.Int64(int64(*num)) +} + +func Int64PtrToIntPtr(num *int64) *int { + if num == nil { + return nil + } + + return swag.Int(int(*num)) +} + +func IntToInt32Ptr(num int) *int32 { + if num > math.MaxInt32 || num < math.MinInt32 { + return nil + } + + return swag.Int32(int32(num)) +} diff --git a/internal/util/int_test.go b/internal/util/int_test.go new file mode 100644 index 0000000..ac0605f --- /dev/null +++ b/internal/util/int_test.go @@ -0,0 +1,27 @@ +package util_test + +import ( + "math" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestTypeConversions(t *testing.T) { + i := 19 + if i > math.MaxInt32 || i < math.MinInt32 { + t.Error("Int value is out of range") + } + + i64 := int64(i) + i32 := int32(i) + res := util.IntPtrToInt64Ptr(&i) + assert.Equal(t, &i64, res) + + res2 := util.Int64PtrToIntPtr(&i64) + assert.Equal(t, &i, res2) + + res3 := util.IntToInt32Ptr(i) + assert.Equal(t, &i32, res3) +} diff --git a/internal/util/lang.go b/internal/util/lang.go new file mode 100644 index 0000000..eefdb2e --- /dev/null +++ b/internal/util/lang.go @@ -0,0 +1,23 @@ +// nolint:revive +package util + +import ( + "sort" + + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +// SortCollateStringSlice is used to sort a slice of strings if the language specific order of caracters is +// important for the order of the string. +// ! The slice passed will be changed. +func SortCollateStringSlice(slice []string, lang language.Tag, options ...collate.Option) { + if len(options) == 0 { + options = []collate.Option{collate.IgnoreCase, collate.IgnoreWidth} + } + coll := collate.New(lang, options...) + + sort.Slice(slice, func(i int, j int) bool { + return coll.CompareString(slice[i], slice[j]) < 0 + }) +} diff --git a/internal/util/lang_test.go b/internal/util/lang_test.go new file mode 100644 index 0000000..89aa1b9 --- /dev/null +++ b/internal/util/lang_test.go @@ -0,0 +1,34 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +func TestSortCollateStringGerman(t *testing.T) { + slice := []string{"a", "ä", "e", "ö", "u", "ü", "o"} + util.SortCollateStringSlice(slice, language.German) + + expected := []string{"a", "ä", "e", "o", "ö", "u", "ü"} + assert.Equal(t, expected, slice) +} + +func TestSortCollateStringEnglish(t *testing.T) { + slice := []string{"a", "ä", "e", "ö", "u", "ü", "o"} + util.SortCollateStringSlice(slice, language.English) + + expected := []string{"a", "ä", "e", "o", "ö", "u", "ü"} + assert.Equal(t, expected, slice) +} + +func TestSortCollateStringGermanAndOptions(t *testing.T) { + slice := []string{"a", "ä", "e", "ö", "u", "ü", "o"} + util.SortCollateStringSlice(slice, language.German, collate.IgnoreCase, collate.IgnoreWidth, collate.IgnoreDiacritics) + + expected := []string{"a", "ä", "e", "ö", "o", "u", "ü"} + assert.Equal(t, expected, slice) +} diff --git a/internal/util/log.go b/internal/util/log.go new file mode 100644 index 0000000..3841131 --- /dev/null +++ b/internal/util/log.go @@ -0,0 +1,44 @@ +// nolint:revive +package util + +import ( + "context" + + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// LogFromContext returns a request-specific zerolog instance using the provided context. +// The returned logger will have the request ID as well as some other value predefined. +// If no logger is associated with the context provided, the global zerolog instance +// will be returned instead - this function will _always_ return a valid (enabled) logger. +// Should you ever need to force a disabled logger for a context, use `util.DisableLogger(ctx, true)` +// and pass the context returned to other code/`LogFromContext`. +func LogFromContext(ctx context.Context) *zerolog.Logger { + logger := log.Ctx(ctx) + if logger.GetLevel() == zerolog.Disabled { + if ShouldDisableLogger(ctx) { + return logger + } + logger = &log.Logger + } + + return logger +} + +// LogFromEchoContext returns a request-specific zerolog instance using the echo.Context of the request. +// The returned logger will have the request ID as well as some other value predefined. +func LogFromEchoContext(c echo.Context) *zerolog.Logger { + return LogFromContext(c.Request().Context()) +} + +func LogLevelFromString(s string) zerolog.Level { + level, err := zerolog.ParseLevel(s) + if err != nil { + log.Error().Err(err).Msgf("Failed to parse log level, defaulting to %s", zerolog.DebugLevel) + return zerolog.DebugLevel + } + + return level +} diff --git a/internal/util/log_test.go b/internal/util/log_test.go new file mode 100644 index 0000000..8a75f10 --- /dev/null +++ b/internal/util/log_test.go @@ -0,0 +1,20 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestLogLevelFromString(t *testing.T) { + res := util.LogLevelFromString("panic") + assert.Equal(t, zerolog.PanicLevel, res) + + res = util.LogLevelFromString("warn") + assert.Equal(t, zerolog.WarnLevel, res) + + res = util.LogLevelFromString("foo") + assert.Equal(t, zerolog.DebugLevel, res) +} diff --git a/internal/util/map.go b/internal/util/map.go new file mode 100644 index 0000000..c446c9a --- /dev/null +++ b/internal/util/map.go @@ -0,0 +1,12 @@ +// nolint:revive +package util + +func MergeStringMap(base map[string]string, toMerge map[string]string) map[string]string { + for k, v := range toMerge { + if _, ok := base[k]; !ok { + base[k] = v + } + } + + return base +} diff --git a/internal/util/map_test.go b/internal/util/map_test.go new file mode 100644 index 0000000..84e6eaf --- /dev/null +++ b/internal/util/map_test.go @@ -0,0 +1,41 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestMergeStringMap(t *testing.T) { + baseMap := map[string]string{ + "A": "a", + "B": "b", + "C": "c", + } + + toMerge := map[string]string{ + "C": "1", + "D": "2", + } + + expected := map[string]string{ + "A": "a", + "B": "b", + "C": "c", + "D": "2", + } + + res := util.MergeStringMap(baseMap, toMerge) + assert.Equal(t, expected, res) + + expected = map[string]string{ + "C": "1", + "D": "2", + "A": "a", + "B": "b", + } + + res = util.MergeStringMap(toMerge, baseMap) + assert.Equal(t, expected, res) +} diff --git a/internal/util/mime/mime.go b/internal/util/mime/mime.go new file mode 100644 index 0000000..bec30cd --- /dev/null +++ b/internal/util/mime/mime.go @@ -0,0 +1,33 @@ +package mime + +import "github.com/gabriel-vasile/mimetype" + +var _ MIME = (*mimetype.MIME)(nil) + +// MIME interface enables to use either *mimetype.MIME or KnownMIME as mimetype. +type MIME interface { + String() string + Extension() string + Is(expectedMIME string) bool +} + +// KnownMIME implements the MIME interface to be able to pass a *mimetype.MIME +// compatible value if the mimetype is already known so mimetype detection is not +// needed. It is therefore possible to skip mimetype detection if the mimetype is known +// or it is not possible to use a readSeeker but a mimetype is required. +type KnownMIME struct { + MimeType string + FileExtension string +} + +func (m *KnownMIME) String() string { + return m.MimeType +} + +func (m *KnownMIME) Extension() string { + return m.FileExtension +} + +func (m *KnownMIME) Is(expectedMIME string) bool { + return expectedMIME == m.MimeType +} diff --git a/internal/util/mime/mime_test.go b/internal/util/mime/mime_test.go new file mode 100644 index 0000000..6dca618 --- /dev/null +++ b/internal/util/mime/mime_test.go @@ -0,0 +1,30 @@ +package mime_test + +import ( + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/internal/util/mime" + "github.com/gabriel-vasile/mimetype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKnownMIME(t *testing.T) { + filePath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg") + + var detectedMIME mime.MIME + var err error + detectedMIME, err = mimetype.DetectFile(filePath) + require.NoError(t, err) + + var knownMIME mime.MIME = &mime.KnownMIME{ + MimeType: "image/jpeg", + FileExtension: ".jpg", + } + + assert.Equal(t, detectedMIME.Extension(), knownMIME.Extension()) + assert.Equal(t, detectedMIME.String(), knownMIME.String()) + assert.True(t, knownMIME.Is(detectedMIME.String())) +} diff --git a/internal/util/oauth2/pkce.go b/internal/util/oauth2/pkce.go new file mode 100644 index 0000000..9386263 --- /dev/null +++ b/internal/util/oauth2/pkce.go @@ -0,0 +1,35 @@ +package oauth2 + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + + "allaboutapps.dev/aw/go-starter/internal/util" +) + +const ( + defaultVerifierLength = 128 +) + +func GetPKCECodeVerifier() (string, error) { + // for details regarding possible characters in verifier, see: + // https://tools.ietf.org/html/rfc7636#section-4.1 + verifier, err := util.GenerateRandomString(defaultVerifierLength, []util.CharRange{util.CharRangeNumeric, util.CharRangeAlphaLowerCase, util.CharRangeAlphaUpperCase}, "-._~") + if err != nil { + return "", fmt.Errorf("failed to generate random string: %w", err) + } + + return verifier, nil +} + +func GetPKCECodeChallengeS256(verifier string) string { + // for details regarding transformation of verifier to challenge see: + // https://tools.ietf.org/html/rfc7636#section-4.2 + // base64 encoding must be unpadded, URL encoding: + // https://tools.ietf.org/html/rfc7636#page-17 + sum := sha256.Sum256([]byte(verifier)) + b64 := base64.RawURLEncoding.EncodeToString(sum[:]) + + return b64 +} diff --git a/internal/util/oauth2/pkce_test.go b/internal/util/oauth2/pkce_test.go new file mode 100644 index 0000000..ed0fcd4 --- /dev/null +++ b/internal/util/oauth2/pkce_test.go @@ -0,0 +1,16 @@ +package oauth2_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util/oauth2" + "github.com/stretchr/testify/assert" +) + +func TestGetPKCECodeChallengeS256(t *testing.T) { + verifier := "U7MEZRmshzwIHRIGvF5iy6FLKgTtUHV0Vb0Hpczh6jJ_XZKcQIurow2LvsjG6hx2k57s9Pz8UmCZTvazosnniTM-z6EC.skJlQMGA~8ue3LMiOWdFYTfsLdX8GKol285" + expected := "Jg697bAjhzV1upYvV9R04784OFNVRAZh2IjeFlMJ8bE" + + challenge := oauth2.GetPKCECodeChallengeS256(verifier) + assert.Equal(t, expected, challenge) +} diff --git a/internal/util/path.go b/internal/util/path.go new file mode 100644 index 0000000..f8b8064 --- /dev/null +++ b/internal/util/path.go @@ -0,0 +1,40 @@ +// nolint:revive +package util + +import ( + "path/filepath" + "strings" +) + +// FileNameWithoutExtension returns the name of the file referenced by the +// provided path without the file's extension. +// The function accepts a full (local) file path as well, only the latest +// element of the path will be considered as a name. +// If the provided path is empty or consists entirely of separators, an +// empty string will be returned. +func FileNameWithoutExtension(path string) string { + base := filepath.Base(path) + if base == "." || base == "/" { + return "" + } + + return strings.TrimSuffix(base, filepath.Ext(path)) +} + +// FileNameAndExtension returns the name of the file referenced by the +// provided path as well as its extension as separated strings. +// The function accepts a full (local) file path as well, only the latest +// element of the path will be considered as a name. +// If the provided path is empty or consists entirely of separators, +// empty strings will be returned. +func FileNameAndExtension(path string) (string, string) { + base := filepath.Base(path) + if base == "." || base == "/" { + return "", "" + } + + extension := filepath.Ext(path) + fileName := strings.TrimSuffix(base, extension) + + return fileName, extension +} diff --git a/internal/util/path_test.go b/internal/util/path_test.go new file mode 100644 index 0000000..c405829 --- /dev/null +++ b/internal/util/path_test.go @@ -0,0 +1,42 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestGetFileNameWithoutExtension(t *testing.T) { + assert.Equal(t, "example", util.FileNameWithoutExtension("/a/b/c/d/example.jpg")) + assert.Equal(t, "example", util.FileNameWithoutExtension("example.jpg")) + assert.Equal(t, "example_test-check", util.FileNameWithoutExtension("example_test-check.jpg")) + assert.Equal(t, "example", util.FileNameWithoutExtension("example")) + assert.Empty(t, util.FileNameWithoutExtension("")) + assert.Empty(t, util.FileNameWithoutExtension(".")) + assert.Empty(t, util.FileNameWithoutExtension("///")) +} + +func TestFileNameAndExtension(t *testing.T) { + name, ext := util.FileNameAndExtension("/a/b/c/d/example.jpg") + assert.Equal(t, "example", name) + assert.Equal(t, ".jpg", ext) + name, ext = util.FileNameAndExtension("example.jpg") + assert.Equal(t, "example", name) + assert.Equal(t, ".jpg", ext) + name, ext = util.FileNameAndExtension("example_test-check.jpg") + assert.Equal(t, "example_test-check", name) + assert.Equal(t, ".jpg", ext) + name, ext = util.FileNameAndExtension("example") + assert.Equal(t, "example", name) + assert.Empty(t, ext) + name, ext = util.FileNameAndExtension("") + assert.Empty(t, name) + assert.Empty(t, ext) + name, ext = util.FileNameAndExtension(".") + assert.Empty(t, name) + assert.Empty(t, ext) + name, ext = util.FileNameAndExtension("///") + assert.Empty(t, name) + assert.Empty(t, ext) +} diff --git a/internal/util/slice.go b/internal/util/slice.go new file mode 100644 index 0000000..d4d127f --- /dev/null +++ b/internal/util/slice.go @@ -0,0 +1,39 @@ +// nolint:revive +package util + +// ContainsAllString checks whether the given string slice contains all strings provided. +func ContainsAllString(slice []string, sub ...string) bool { + contains := make(map[string]bool) + for _, v := range sub { + contains[v] = false + } + + for _, v := range slice { + if _, ok := contains[v]; ok { + contains[v] = true + } + } + + for _, v := range contains { + if !v { + return false + } + } + + return true +} + +// UniqueString takes the string slice provided and returns a new slice with all duplicates removed. +func UniqueString(slice []string) []string { + seen := make(map[string]struct{}) + res := make([]string, 0) + + for _, s := range slice { + if _, ok := seen[s]; !ok { + res = append(res, s) + seen[s] = struct{}{} + } + } + + return res +} diff --git a/internal/util/slice_test.go b/internal/util/slice_test.go new file mode 100644 index 0000000..206803d --- /dev/null +++ b/internal/util/slice_test.go @@ -0,0 +1,31 @@ +package util_test + +import ( + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestContainsAllString(t *testing.T) { + test := []string{"a", "b", "d"} + assert.True(t, util.ContainsAllString(test, "a")) + assert.True(t, util.ContainsAllString(test, "b")) + assert.False(t, util.ContainsAllString(test, "c")) + assert.True(t, util.ContainsAllString(test, "d")) + assert.True(t, util.ContainsAllString(test, "a", "b")) + assert.True(t, util.ContainsAllString(test, "a", "d")) + assert.True(t, util.ContainsAllString(test, "b", "d")) + assert.False(t, util.ContainsAllString(test, "a", "c")) + assert.False(t, util.ContainsAllString(test, "b", "c")) + assert.False(t, util.ContainsAllString(test, "c", "d")) + assert.True(t, util.ContainsAllString(test, "a", "b", "d")) + assert.False(t, util.ContainsAllString(test, "a", "b", "c")) + assert.False(t, util.ContainsAllString(test, "a", "b", "c", "d")) + assert.True(t, util.ContainsAllString(test)) +} + +func TestUniqueString(t *testing.T) { + test := []string{"a", "b", "d", "d", "a", "d"} + assert.Equal(t, []string{"a", "b", "d"}, util.UniqueString(test)) +} diff --git a/internal/util/string.go b/internal/util/string.go new file mode 100644 index 0000000..b98ebb5 --- /dev/null +++ b/internal/util/string.go @@ -0,0 +1,185 @@ +// nolint:revive +package util + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/go-openapi/swag" +) + +var ( + StringSpaceReplacer = regexp.MustCompile(`\s+`) +) + +// GenerateRandomBytes returns n random bytes securely generated using the system's default CSPRNG. +// +// An error will be returned if reading from the secure random number generator fails, at which point +// the returned result should be discarded and not used any further. +func GenerateRandomBytes(n int) ([]byte, error) { + result := make([]byte, n) + + _, err := rand.Read(result) + if err != nil { + return nil, fmt.Errorf("failed to generate random bytes: %w", err) + } + + return result, nil +} + +// GenerateRandomBase64String returns a string with n random bytes securely generated using the system's +// default CSPRNG in base64 encoding. The resulting string might not be of length n as the encoding for +// the raw bytes generated may vary. +// +// An error will be returned if reading from the secure random number generator fails, at which point +// the returned result should be discarded and not used any further. +func GenerateRandomBase64String(n int) (string, error) { + b, err := GenerateRandomBytes(n) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(b), nil +} + +// GenerateRandomHexString returns a string with n random bytes securely generated using the system's +// default CSPRNG in hexadecimal encoding. The resulting string might not be of length n as the encoding +// for the raw bytes generated may vary. +// +// An error will be returned if reading from the secure random number generator fails, at which point +// the returned result should be discarded and not used any further. +func GenerateRandomHexString(n int) (string, error) { + b, err := GenerateRandomBytes(n) + if err != nil { + return "", err + } + + return hex.EncodeToString(b), nil +} + +type CharRange int + +const ( + CharRangeNumeric CharRange = iota + CharRangeAlphaLowerCase + CharRangeAlphaUpperCase +) + +// GenerateRandomString returns a string with n random bytes securely generated using the system's +// default CSPRNG. The characters within the generated string will either be part of one ore more supplied +// range of characters, or based on characters in the extra string supplied. +// +// An error will be returned if reading from the secure random number generator fails, at which point +// the returned result should be discarded and not used any further. +func GenerateRandomString(n int, ranges []CharRange, extra string) (string, error) { + var str strings.Builder + + if len(ranges) == 0 && len(extra) == 0 { + return "", errors.New("random string can only be created if set of characters or extra string characters supplied") + } + + validateFn := func(elem byte) bool { + // IndexByte(string, byte) is basically Contains(string, string) without casting + if strings.IndexByte(extra, elem) >= 0 { + return true + } + + for _, r := range ranges { + switch r { + case CharRangeNumeric: + if elem >= '0' && elem <= '9' { + return true + } + case CharRangeAlphaLowerCase: + if elem >= 'a' && elem <= 'z' { + return true + } + case CharRangeAlphaUpperCase: + if elem >= 'A' && elem <= 'Z' { + return true + } + } + } + + return false + } + + for str.Len() < n { + buf, err := GenerateRandomBytes(n) + if err != nil { + return "", err + } + + for _, b := range buf { + if validateFn(b) { + str.WriteByte(b) + } + if str.Len() >= n { + break + } + } + } + + return str.String(), nil +} + +// Lowercases a string and trims whitespace from the beginning and end of the string +func ToUsernameFormat(s string) string { + return strings.TrimSpace(strings.ToLower(s)) +} + +// NonEmptyOrNil returns a pointer to passed string if it is not empty. Passing empty strings returns nil instead. +func NonEmptyOrNil(s string) *string { + if len(s) > 0 { + return swag.String(s) + } + + return nil +} + +// EmptyIfNil returns an empty string if the passed pointer is nil. Passing a pointer to a string will return the value of the string. +func EmptyIfNil(s *string) string { + if s == nil { + return "" + } + + return *s +} + +// ContainsAll returns true if a string (str) contains all substrings (sub). +func ContainsAll(str string, subs ...string) bool { + subLen := len(subs) + contains := make([]bool, subLen) + indices := make([]int, subLen) + substrings := make([][]rune, subLen) + for i, substring := range subs { + substrings[i] = []rune(substring) + } + + for _, marked := range str { + for i, sub := range substrings { + if len(sub) == 0 { + contains[i] = true + } + if !contains[i] && marked == sub[indices[i]] { + indices[i]++ + if indices[i] >= len(sub) { + contains[i] = true + } + } + } + } + + for _, c := range contains { + if !c { + return false + } + } + + return true +} diff --git a/internal/util/string_test.go b/internal/util/string_test.go new file mode 100644 index 0000000..9abe26f --- /dev/null +++ b/internal/util/string_test.go @@ -0,0 +1,82 @@ +package util_test + +import ( + "encoding/base64" + "encoding/hex" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateRandom(t *testing.T) { + res, err := util.GenerateRandomBytes(13) + require.NoError(t, err) + assert.Len(t, res, 13) + + randString, err := util.GenerateRandomBase64String(17) + require.NoError(t, err) + res, err = base64.StdEncoding.DecodeString(randString) + require.NoError(t, err) + assert.Len(t, res, 17) + + randString, err = util.GenerateRandomHexString(19) + require.NoError(t, err) + res, err = hex.DecodeString(randString) + require.NoError(t, err) + assert.Len(t, res, 19) + + randString, err = util.GenerateRandomString(19, []util.CharRange{util.CharRangeAlphaLowerCase}, "/%$") + require.NoError(t, err) + assert.Len(t, randString, 19) + for _, r := range randString { + assert.True(t, (r >= 'a' && r <= 'z') || r == '/' || r == '%' || r == '$') + } + + randString, err = util.GenerateRandomString(19, []util.CharRange{util.CharRangeAlphaUpperCase}, "^\"") + require.NoError(t, err) + assert.Len(t, randString, 19) + for _, r := range randString { + assert.True(t, (r >= 'A' && r <= 'Z') || r == '^' || r == '"') + } + + randString, err = util.GenerateRandomString(19, []util.CharRange{util.CharRangeNumeric}, "") + require.NoError(t, err) + assert.Len(t, randString, 19) + for _, r := range randString { + assert.True(t, (r >= '0' && r <= '9')) + } + + _, err = util.GenerateRandomString(1, nil, "") + require.Error(t, err) + + randString, err = util.GenerateRandomString(8, nil, "a") + require.NoError(t, err) + assert.Len(t, randString, 8) + assert.Equal(t, "aaaaaaaa", randString) +} + +func TestNonEmptyOrNil(t *testing.T) { + assert.Equal(t, "test", *util.NonEmptyOrNil("test")) + assert.Equal(t, (*string)(nil), util.NonEmptyOrNil("")) +} + +func TestContainsAll(t *testing.T) { + assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", "dolor")) + assert.False(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", "dolorx")) + + assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", ".", "sit", "elit", "ipsum", "Lorem ipsum")) + assert.False(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", ".", "sit", "elit", "ipsum", " Lorem")) + + assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, ÄÜiö consectetur adipiscing elit.", "ÄÜiö c")) + + assert.False(t, util.ContainsAll("", "ÄÜiö c")) + assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, ÄÜiö consectetur adipiscing elit.", "")) +} + +func TestEmptyIfNil(t *testing.T) { + s := "Lorem ipsum" + assert.Equal(t, s, util.EmptyIfNil(&s)) + assert.Empty(t, util.EmptyIfNil(nil)) +} diff --git a/internal/util/struct.go b/internal/util/struct.go new file mode 100644 index 0000000..7b2c78d --- /dev/null +++ b/internal/util/struct.go @@ -0,0 +1,107 @@ +// nolint:revive +package util + +import ( + "errors" + "reflect" +) + +// GetFieldsImplementing returns all fields of a struct implementing a certain interface. +// Returned fields are pointers to a type or interface objects. +// +// Parameter structPtr must be a pointer to a struct. +// Parameter interfaceObject must be given as a pointer to an interface, +// for example (*Insertable)(nil), where Insertable is an interface name. +func GetFieldsImplementing[T any](structPtr interface{}, interfaceObject *T) ([]T, error) { + // Verify if structPtr is a pointer to a struct + inputParamStructType := reflect.TypeOf(structPtr) + if inputParamStructType == nil || + inputParamStructType.Kind() != reflect.Ptr || + inputParamStructType.Elem().Kind() != reflect.Struct { + return nil, errors.New("invalid input structPtr param: should be a pointer to a struct") + } + + inputParamIfcType := reflect.TypeOf(interfaceObject) + // Verify if interfaceObject is a pointer to an interface + if inputParamIfcType == nil || + inputParamIfcType.Kind() != reflect.Ptr || + inputParamIfcType.Elem().Kind() != reflect.Interface { + return nil, errors.New("invalid input interfaceObject param: should be a pointer to an interface") + } + + // We need the type, not the pointer to it. + // By using Elem() we can get the value pointed by the pointer. + interfaceType := inputParamIfcType.Elem() + structType := inputParamStructType.Elem() + + structValue := reflect.ValueOf(structPtr).Elem() + + retFields := make([]T, 0) + + // Getting the VisibleFields returns all public fields in the struct + for i, field := range reflect.VisibleFields(structType) { + // Check if the field can be exported. + // Interface() can be called only on exportable fields. + if !field.IsExported() { + continue + } + + fieldValue := structValue.Field(i) + + // Depending on the field type, different checks apply. + switch field.Type.Kind() { + case reflect.Pointer: + // Let's check if it implements the interface. + if field.Type.Implements(interfaceType) { + // Great, we can add it to the return slice + //nolint:forcetypeassert + retFields = append(retFields, fieldValue.Interface().(T)) + } + + case reflect.Interface: + // If it's an interface, make sure it's not nil. + if fieldValue.IsNil() { + continue + } + + // Now we can check if it's the same interface. + if field.Type.Implements(interfaceType) { + // Great, we can add it to the return slice + //nolint:forcetypeassert + retFields = append(retFields, fieldValue.Interface().(T)) + } + + default: + // We can skip any other cases. + continue + } + } + + return retFields, nil +} + +// IsStructInitialized checks if all the struct fields are initialized (not zero). +// Members of the struct such as empty strings or numbers set to zero are interpreted as a zero value! +// Parameter structPtr needs to be a pointer to a struct. +func IsStructInitialized(structPtr interface{}) error { + inputType := reflect.TypeOf(structPtr) + if inputType == nil || + inputType.Kind() != reflect.Pointer || + inputType.Elem().Kind() != reflect.Struct { + return errors.New("invalid input structPtr param: should be a pointer to a struct") + } + + // we want to access values of the struct, not value of the pointer, therefore we use Elem() + structVal := reflect.ValueOf(structPtr).Elem() + structType := inputType.Elem() + + var err error + for i := 0; i < structVal.NumField(); i++ { + field := structVal.Field(i) + if field.IsValid() && field.IsZero() { + err = errors.Join(err, errors.New(structType.Field(i).Name+" is not initialized")) + } + } + + return err +} diff --git a/internal/util/struct_test.go b/internal/util/struct_test.go new file mode 100644 index 0000000..70be528 --- /dev/null +++ b/internal/util/struct_test.go @@ -0,0 +1,321 @@ +package util_test + +import ( + "bytes" + "io" + "net" + "strings" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/go-openapi/swag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type readInterface interface { + Read(p []byte) (n int, err error) +} + +type writeInterface interface { + WriteTo(w io.Writer) (n int64, err error) +} + +type testStruct struct { + // satisfy only readInterface + LimitedReader *io.LimitedReader + Reader io.Reader + + // satisfy both readInterface and writeInterface + Buffer1 *bytes.Buffer + Buffer2 *bytes.Buffer + NetBuffer *net.Buffers +} + +func TestGetFieldsImplementingInvalidInput(t *testing.T) { + // Invalid interfaceObject input param, must be a pointer to an interface + // Pointer to a struct + _, err := util.GetFieldsImplementing(&testStructEmpty{}, &testStructEmpty{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "interfaceObject") + // Pointer to a pointer to an interface + interfaceObjPtr := (*readInterface)(nil) + _, err = util.GetFieldsImplementing(&testStructEmpty{}, &interfaceObjPtr) + require.Error(t, err) + assert.Contains(t, err.Error(), "interfaceObject") + + // Invalid structPtr input param, must be a pointer to a struct + _, err = util.GetFieldsImplementing(testStructEmpty{}, (*readInterface)(nil)) + require.Error(t, err) + assert.Contains(t, err.Error(), "structPtr") + _, err = util.GetFieldsImplementing((*readInterface)(nil), (*readInterface)(nil)) + require.Error(t, err) + assert.Contains(t, err.Error(), "structPtr") + _, err = util.GetFieldsImplementing([]*testStructEmpty{}, (*readInterface)(nil)) + require.Error(t, err) + assert.Contains(t, err.Error(), "structPtr") +} + +func TestGetFieldsImplementingNoFields(t *testing.T) { + // No fields returned from empty structs + structEmptyFields, err := util.GetFieldsImplementing(&testStructEmpty{}, (*readInterface)(nil)) + require.NoError(t, err) + assert.Empty(t, structEmptyFields) + + // No fields returned from structs with only private fields + structPrivate := testStructPrivateFiled{privateMember: bytes.NewBufferString("my content")} + structPrivateFields, err := util.GetFieldsImplementing(&structPrivate, (*readInterface)(nil)) + require.NoError(t, err) + assert.Empty(t, structPrivateFields) + + // No fields returned if struct fields are primitive + structPrimitive := testStructPrimitives{X: 12, Y: "y", XPtr: swag.Int(15), YPtr: swag.String("YPtr")} + structPrimitiveFields, err := util.GetFieldsImplementing(&structPrimitive, (*readInterface)(nil)) + require.NoError(t, err) + assert.Empty(t, structPrimitiveFields) + + // No fields returned if struct fields are structs (not pointer to a struct) + structMemberStruct := testStructMemberStruct{Member: *bytes.NewBufferString("my content")} + structMemberStructFields, err := util.GetFieldsImplementing(&structMemberStruct, (*readInterface)(nil)) + require.NoError(t, err) + assert.Empty(t, structMemberStructFields) + + // No fieds returned if an interface is not matching + type notMatchedInterface interface { + Read(p []byte) (n int, err error, additional []string) + } + testStructObj := testStruct{} + testStructFields, err := util.GetFieldsImplementing(&testStructObj, (*notMatchedInterface)(nil)) + require.NoError(t, err) + assert.Empty(t, testStructFields) +} + +func TestGetFieldsImplementingMemberStructPointer(t *testing.T) { + content := "runs all day and never walks" + testStructObj := testStructMemberStructPtr{ + Member: bytes.NewBufferString(content), + } + fields, err := util.GetFieldsImplementing(&testStructObj, (*readInterface)(nil)) + require.NoError(t, err) + assert.Len(t, fields, 1) + + output := make([]byte, len(content)) + n, err := fields[0].Read(output) + require.NoError(t, err) + assert.Equal(t, len(content), n) + assert.Equal(t, content, string(output)) +} + +func TestGetFieldsImplementingMemberInterface(t *testing.T) { + content := "it has a bed and never sleeps" + testStructObj := testStructMemberInterface{ + Member: bytes.NewBufferString(content), + } + fields, err := util.GetFieldsImplementing(&testStructObj, (*readInterface)(nil)) + require.NoError(t, err) + assert.Len(t, fields, 1) + + output := make([]byte, len(content)) + n, err := fields[0].Read(output) + require.NoError(t, err) + assert.Equal(t, len(content), n) + assert.Equal(t, content, string(output)) +} + +func TestGetFieldsImplementingSuccess(t *testing.T) { + // Struct not initialized + // It's a responsibility of a user to make sure that the fields are not nil before using them. + structNotInitialized := testStruct{} + structNotInitializedFields, err := util.GetFieldsImplementing(&structNotInitialized, (*readInterface)(nil)) + require.NoError(t, err) + // There are 4 pointer members of the testStruct satisfying the interface. + // Nil interface members are not returned. + assert.Len(t, structNotInitializedFields, 4) + for _, f := range structNotInitializedFields { + assert.Nil(t, f) + assert.Implements(t, (*readInterface)(nil), f) + } + + // Struct initialized + testStructObj := testStruct{ + // satisfy only readInterface + LimitedReader: &io.LimitedReader{N: 100}, + Reader: strings.NewReader("did you know that"), + // satisfy both readInterface and writeInterface + Buffer1: bytes.NewBufferString("there are rats with"), + Buffer2: bytes.NewBufferString("human BRAIN cells transplanted"), + NetBuffer: &net.Buffers{[]byte{0x19}}, + } + + // Fields implementing readInterface + readInterfaceFields, err := util.GetFieldsImplementing(&testStructObj, (*readInterface)(nil)) + require.NoError(t, err) + assert.Len(t, readInterfaceFields, 5) + + for _, f := range readInterfaceFields { + assert.NotNil(t, f) + assert.Implements(t, (*readInterface)(nil), f) + } + + // Fields implementing writeInterface + writeInterfaceFields, err := util.GetFieldsImplementing(&testStructObj, (*writeInterface)(nil)) + require.NoError(t, err) + assert.Len(t, writeInterfaceFields, 3) + for _, f := range writeInterfaceFields { + assert.NotNil(t, f) + assert.Implements(t, (*writeInterface)(nil), f) + } + + type readWriteInterface interface { + readInterface + writeInterface + } + readWriteInterfaceFields, err := util.GetFieldsImplementing(&testStructObj, (*readWriteInterface)(nil)) + require.NoError(t, err) + // All members implementing writeInterface implement readInterface too + assert.Len(t, readWriteInterfaceFields, 3) +} + +func TestIsStructInitialized(t *testing.T) { + tests := []struct { + name string + testStruct interface{} + expectError bool + errorContains []string + }{ + // No error cases + { + name: "Empty struct", + testStruct: &testStructEmpty{}, + expectError: false, + }, + { + name: "Struct with initialized private field", + testStruct: &testStructPrivateFiled{privateMember: bytes.NewBufferString("my content")}, + expectError: false, + }, + { + name: "Struct with initialized primitives", + testStruct: &testStructPrimitives{ + X: 1, + Y: "blabla", + XPtr: new(int), + YPtr: new(string), + }, + expectError: false, + }, + { + name: "Struct with initialized maps and slices", + testStruct: &testStructMapsAndSlices{ + MapMember: make(map[string]int, 0), + SliceMember: make([]int, 0), + }, + expectError: false, + }, + { + name: "Struct with initialized struct member", + testStruct: &testStructMemberStruct{ + Member: *bytes.NewBufferString("my content"), + }, + expectError: false, + }, + { + name: "Struct with initialized struct pointer member", + testStruct: &testStructMemberStructPtr{ + Member: bytes.NewBufferString("my content"), + }, + expectError: false, + }, + { + name: "Struct with initialized interface member", + testStruct: &testStructMemberInterface{ + Member: bytes.NewBufferString("my content"), + }, + expectError: false, + }, + + // Error cases + { + name: "Struct with uninitialized private field", + testStruct: &testStructPrivateFiled{}, + expectError: true, + errorContains: []string{"privateMember"}, + }, + { + name: "Struct with uninitialized primitives", + testStruct: &testStructPrimitives{}, + expectError: true, + errorContains: []string{"X", "Y", "XPtr", "YPtr"}, + }, + { + name: "Struct with uninitialized maps and slices", + testStruct: &testStructMapsAndSlices{}, + expectError: true, + errorContains: []string{"MapMember", "SliceMember"}, + }, + { + name: "Struct with uninitialized struct member", + testStruct: &testStructMemberStruct{}, + expectError: true, + errorContains: []string{"Member"}, + }, + { + name: "Struct with uninitialized struct pointer member", + testStruct: &testStructMemberStructPtr{}, + expectError: true, + errorContains: []string{"Member"}, + }, + { + name: "Struct with uninitialized interface member", + testStruct: &testStructMemberInterface{}, + expectError: true, + errorContains: []string{"Member"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := util.IsStructInitialized(tt.testStruct) + + if tt.expectError { + require.Error(t, err) + for _, errText := range tt.errorContains { + assert.Contains(t, err.Error(), errText) + } + } else { + require.NoError(t, err) + } + }) + } +} + +type testStructEmpty struct { +} + +type testStructPrivateFiled struct { + privateMember *bytes.Buffer +} + +type testStructPrimitives struct { + X int + Y string + XPtr *int + YPtr *string +} + +type testStructMapsAndSlices struct { + MapMember map[string]int + SliceMember []int +} + +type testStructMemberStruct struct { + Member bytes.Buffer +} + +type testStructMemberStructPtr struct { + Member *bytes.Buffer +} + +type testStructMemberInterface struct { + Member io.Reader +} diff --git a/internal/util/time.go b/internal/util/time.go new file mode 100644 index 0000000..46f6670 --- /dev/null +++ b/internal/util/time.go @@ -0,0 +1,113 @@ +// nolint:revive +package util + +import ( + "fmt" + "time" + + "github.com/go-openapi/swag" +) + +const ( + DateFormat = "2006-01-02" + monthsPerQuarter = 3 + daysPerWeek = 7 +) + +// TimeFromString returns an instance of time.Time from a given string asuming RFC3339 format +func TimeFromString(timeString string) (time.Time, error) { + result, err := time.Parse(time.RFC3339, timeString) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse time string: %w", err) + } + + return result, nil +} + +func DateFromString(dateString string) (time.Time, error) { + result, err := time.Parse(DateFormat, dateString) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse date string: %w", err) + } + + return result, nil +} + +func EndOfMonth(d time.Time) time.Time { + return time.Date(d.Year(), d.Month()+1, 1, 0, 0, 0, -1, d.Location()) +} + +func EndOfPreviousMonth(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), 1, 0, 0, 0, -1, d.Location()) +} + +func EndOfDay(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day()+1, 0, 0, 0, -1, d.Location()) +} + +func StartOfDay(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) +} + +func StartOfMonth(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), 1, 0, 0, 0, 0, d.Location()) +} + +func StartOfQuarter(d time.Time) time.Time { + quarter := (int(d.Month()) - 1) / monthsPerQuarter + m := quarter*monthsPerQuarter + 1 + return time.Date(d.Year(), time.Month(m), 1, 0, 0, 0, 0, d.Location()) +} + +// StartOfWeek returns the monday (assuming week starts at monday) of the week of the date. +func StartOfWeek(date time.Time) time.Time { + dayOffset := int(date.Weekday()) - 1 + + // go time is starting weeks at sunday + if dayOffset < 0 { + dayOffset = 6 + } + + return time.Date(date.Year(), date.Month(), date.Day()-dayOffset, 0, 0, 0, 0, date.Location()) +} + +func Date(year int, month int, day int, loc *time.Location) time.Time { + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) +} + +func AddWeeks(d time.Time, weeks int) time.Time { + return d.AddDate(0, 0, daysPerWeek*weeks) +} + +func AddMonths(d time.Time, months int) time.Time { + return d.AddDate(0, months, 0) +} + +func DayBefore(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, -1, d.Location()) +} + +func TruncateTime(d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) +} + +// MaxTime returns the latest time.Time of the given params +func MaxTime(times ...time.Time) time.Time { + var latestTime time.Time + for _, t := range times { + if t.After(latestTime) { + latestTime = t + } + } + + return latestTime +} + +// NonZeroTimeOrNil returns a pointer to passed time if it is not a zero time. Passing zero/uninitialised time returns nil instead. +func NonZeroTimeOrNil(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + + return swag.Time(t) +} diff --git a/internal/util/time_test.go b/internal/util/time_test.go new file mode 100644 index 0000000..f8e911d --- /dev/null +++ b/internal/util/time_test.go @@ -0,0 +1,174 @@ +package util_test + +import ( + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStartOfMonth(t *testing.T) { + d := util.Date(2020, 3, 12, time.UTC) + expected := time.Date(2020, 3, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfMonth(d)) + + d = util.Date(2020, 12, 35, time.UTC) + expected = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfMonth(d)) +} + +func TestTimeFromString(t *testing.T) { + expected := time.Date(2020, 3, 29, 12, 34, 54, 0, time.UTC) + + d, err := util.TimeFromString("2020-03-29T12:34:54Z") + require.NoError(t, err) + + assert.Equal(t, expected, d) +} + +func TestStartOfQuarter(t *testing.T) { + d := util.Date(2020, 3, 31, time.UTC) + expected := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfQuarter(d)) + + d = util.Date(2020, 1, 1, time.UTC) + expected = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfQuarter(d)) + + d = util.Date(2020, 12, 1, time.UTC) + expected = time.Date(2020, 10, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfQuarter(d)) + + d = util.Date(2020, 12, 35, time.UTC) + expected = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfQuarter(d)) + + d = util.Date(2020, 4, 1, time.UTC) + expected = time.Date(2020, 4, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfQuarter(d)) +} + +func TestStartOfWeek(t *testing.T) { + d := util.Date(2020, 3, 12, time.UTC) + expected := time.Date(2020, 3, 9, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfWeek(d)) + + d = util.Date(2020, 6, 15, time.UTC) + expected = time.Date(2020, 6, 15, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfWeek(d)) + + d = util.Date(2020, 6, 21, time.UTC) + expected = time.Date(2020, 6, 15, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.StartOfWeek(d)) +} + +func TestDateFromString(t *testing.T) { + res, err := util.DateFromString("2020-01-03") + require.NoError(t, err) + + require.True(t, res.Equal(time.Date(2020, 1, 3, 0, 0, 0, 0, time.UTC))) + + res, err = util.DateFromString("2020-xx-03") + require.Error(t, err) + assert.Empty(t, res) +} + +func TestEndOfMonth(t *testing.T) { + d := util.Date(2020, 3, 12, time.UTC) + expected := time.Date(2020, 3, 31, 23, 59, 59, 999999999, time.UTC) + assert.True(t, expected.Equal(util.EndOfMonth(d))) + + d = util.Date(2020, 12, 35, time.UTC) + expected = time.Date(2021, 1, 31, 23, 59, 59, 999999999, time.UTC) + res := util.EndOfMonth(d) + assert.True(t, expected.Equal(res)) + + expected = time.Date(2021, 1, 31, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.TruncateTime(res)) +} + +func TestEndOfPreviousMonth(t *testing.T) { + d := util.Date(2020, 3, 12, time.UTC) + expected := time.Date(2020, 2, 29, 23, 59, 59, 999999999, time.UTC) + assert.True(t, expected.Equal(util.EndOfPreviousMonth(d))) + + d = util.Date(2020, 12, 35, time.UTC) + expected = time.Date(2020, 12, 31, 23, 59, 59, 999999999, time.UTC) + res := util.EndOfPreviousMonth(d) + assert.True(t, expected.Equal(res)) + + expected = time.Date(2020, 12, 31, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.TruncateTime(res)) +} + +func TestStartOfDay(t *testing.T) { + d := time.Date(2020, 3, 12, 23, 59, 59, 999999999, time.UTC) + expected := util.Date(2020, 3, 12, time.UTC) + assert.True(t, expected.Equal(util.StartOfDay(d))) + + d = time.Date(2021, 1, 4, 23, 59, 59, 999999999, time.UTC) + expected = util.Date(2020, 12, 35, time.UTC) + res := util.StartOfDay(d) + assert.True(t, expected.Equal(res)) +} + +func TestEndOfDay(t *testing.T) { + d := util.Date(2020, 3, 12, time.UTC) + expected := time.Date(2020, 3, 12, 23, 59, 59, 999999999, time.UTC) + assert.True(t, expected.Equal(util.EndOfDay(d))) + + d = util.Date(2020, 12, 35, time.UTC) + expected = time.Date(2021, 1, 4, 23, 59, 59, 999999999, time.UTC) + res := util.EndOfDay(d) + assert.True(t, expected.Equal(res)) + + expected = time.Date(2021, 1, 4, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.TruncateTime(res)) +} + +func TestDateAdds(t *testing.T) { + d := util.Date(2020, 3, 12, time.UTC) + expected := time.Date(2022, 4, 12, 0, 0, 0, 0, time.UTC) + res := util.AddMonths(d, 25) + assert.True(t, expected.Equal(res)) + + d = util.Date(2020, 1, 30, time.UTC) + expected = time.Date(2020, 3, 1, 0, 0, 0, 0, time.UTC) + res = util.AddMonths(d, 1) + assert.True(t, expected.Equal(res)) + + d = util.Date(2020, 1, 30, time.UTC) + expected = time.Date(2020, 3, 5, 0, 0, 0, 0, time.UTC) + res = util.AddWeeks(d, 5) + assert.True(t, expected.Equal(res)) +} + +func TestDayBefore(t *testing.T) { + d := util.Date(2020, 3, 1, time.UTC) + expected := time.Date(2020, 2, 29, 23, 59, 59, 999999999, time.UTC) + res := util.DayBefore(d) + assert.True(t, expected.Equal(res)) + + expected = time.Date(2020, 2, 29, 0, 0, 0, 0, time.UTC) + assert.Equal(t, expected, util.TruncateTime(res)) +} + +func TestMaxTime(t *testing.T) { + a := time.Date(2022, 4, 12, 0, 0, 0, 1, time.UTC) + b := time.Date(2022, 4, 12, 0, 0, 0, 2, time.UTC) + c := time.Date(2022, 4, 12, 0, 0, 0, 0, time.UTC) + latestTime := util.MaxTime(a, b, c) + assert.Equal(t, b, latestTime) +} + +func TestNonZeroTimeOrNil(t *testing.T) { + d := time.Time{} + res := util.NonZeroTimeOrNil(d) + assert.Empty(t, res) + + d = util.Date(2021, 7, 2, time.UTC) + res = util.NonZeroTimeOrNil(d) + assert.Equal(t, &d, res) +} diff --git a/internal/util/url/url.go b/internal/util/url/url.go new file mode 100644 index 0000000..33a308b --- /dev/null +++ b/internal/util/url/url.go @@ -0,0 +1,55 @@ +package url + +import ( + "fmt" + "net/url" + "path" + + "allaboutapps.dev/aw/go-starter/internal/config" +) + +const ( + queryParamToken = "token" + accountConfirmationPath = "/api/v1/auth/register" +) + +func PasswordResetDeeplinkURL(config config.Server, token string) (*url.URL, error) { + u, err := url.Parse(config.Frontend.BaseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse the base URL: %w", err) + } + + u.Path = path.Join(u.Path, config.Frontend.PasswordResetEndpoint) + + q := u.Query() + q.Set(queryParamToken, token) + u.RawQuery = q.Encode() + + return u, nil +} + +func ConfirmationDeeplinkURL(config config.Server, token string) (*url.URL, error) { + u, err := url.Parse(config.Echo.BaseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse the base URL: %w", err) + } + + u.Path = path.Join(u.Path, accountConfirmationPath) + + q := u.Query() + q.Set(queryParamToken, token) + u.RawQuery = q.Encode() + + return u, nil +} + +func ConfirmationRequestURL(config config.Server, token string) (*url.URL, error) { + u, err := url.Parse(config.Echo.BaseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse the base URL: %w", err) + } + + u.Path = path.Join(u.Path, accountConfirmationPath, token) + + return u, nil +} diff --git a/internal/util/wait_group.go b/internal/util/wait_group.go new file mode 100644 index 0000000..7a69fef --- /dev/null +++ b/internal/util/wait_group.go @@ -0,0 +1,30 @@ +// nolint:revive +package util + +import ( + "errors" + "sync" + "time" +) + +var ( + ErrWaitTimeout = errors.New("WaitGroup has timed out") +) + +// WaitTimeout waits for the waitgroup for the specified max timeout. +// Returns nil on completion or ErrWaitTimeout if waiting timed out. +// See https://stackoverflow.com/questions/32840687/timeout-for-waitgroup-wait +// Note that the spawned goroutine to wg.Wait() gets leaked and will continue running detached +func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) error { + waitChan := make(chan struct{}) + go func() { + defer close(waitChan) + wg.Wait() + }() + select { + case <-waitChan: + return nil // completed normally + case <-time.After(timeout): + return ErrWaitTimeout // timed out + } +} diff --git a/internal/util/wait_group_test.go b/internal/util/wait_group_test.go new file mode 100644 index 0000000..108a700 --- /dev/null +++ b/internal/util/wait_group_test.go @@ -0,0 +1,48 @@ +package util_test + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "allaboutapps.dev/aw/go-starter/internal/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWaitTimeoutErr(t *testing.T) { + var wg sync.WaitGroup + var n int32 + + wg.Add(1) + go func() { + atomic.AddInt32(&n, 1) + time.Sleep(10 * time.Millisecond) + atomic.AddInt32(&n, 1) + wg.Done() + }() + + wg.Add(1) + go func() { + atomic.AddInt32(&n, 1) + wg.Done() + }() + + wg.Add(1) + go func() { + time.Sleep(400 * time.Millisecond) + atomic.AddInt32(&n, 1) // available after wg.Wait() + wg.Done() + }() + + // timeout reached. + err := util.WaitTimeout(&wg, 200*time.Millisecond) + assert.Equal(t, util.ErrWaitTimeout, err) + assert.Equal(t, int32(3), atomic.LoadInt32(&n)) + + // ok (after timeout). + err = util.WaitTimeout(&wg, 800*time.Second) + require.NoError(t, err) + assert.Equal(t, int32(4), atomic.LoadInt32(&n)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0036331 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "allaboutapps.dev/aw/go-starter/cmd" + +func main() { + cmd.Execute() +} diff --git a/migrations/20200428064736-install-extension-uuid.sql b/migrations/20200428064736-install-extension-uuid.sql new file mode 100644 index 0000000..a944ca9 --- /dev/null +++ b/migrations/20200428064736-install-extension-uuid.sql @@ -0,0 +1,6 @@ +-- +migrate Up +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- +migrate Down +DROP EXTENSION IF EXISTS "uuid-ossp"; + diff --git a/migrations/20200428064802-create-user-scope-enum.sql b/migrations/20200428064802-create-user-scope-enum.sql new file mode 100644 index 0000000..6cf4504 --- /dev/null +++ b/migrations/20200428064802-create-user-scope-enum.sql @@ -0,0 +1,9 @@ +-- +migrate Up +CREATE TYPE user_scope AS ENUM ( + 'app', + 'cms' +); + +-- +migrate Down +DROP TYPE IF EXISTS user_scope; + diff --git a/migrations/20200428072232-create-users.sql b/migrations/20200428072232-create-users.sql new file mode 100644 index 0000000..f456e3e --- /dev/null +++ b/migrations/20200428072232-create-users.sql @@ -0,0 +1,19 @@ +-- +migrate Up +CREATE TABLE users ( + id uuid NOT NULL DEFAULT uuid_generate_v4 (), + username varchar(255), + "password" text, + is_active bool NOT NULL, + -- TODO: use user_scope enum as "scopes user_scope[]" when supported + -- https://github.com/volatiletech/sqlboiler/issues/739 + scopes text[] NOT NULL, + last_authenticated_at timestamptz, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT users_pkey PRIMARY KEY (id), + CONSTRAINT users_username_key UNIQUE (username) +); + +-- +migrate Down +DROP TABLE IF EXISTS users; + diff --git a/migrations/20200428072359-create-app-user-profiles.sql b/migrations/20200428072359-create-app-user-profiles.sql new file mode 100644 index 0000000..2b59580 --- /dev/null +++ b/migrations/20200428072359-create-app-user-profiles.sql @@ -0,0 +1,15 @@ +-- +migrate Up +CREATE TABLE app_user_profiles ( + user_id uuid NOT NULL, + legal_accepted_at timestamptz, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT app_user_profiles_pkey PRIMARY KEY (user_id) +); + +ALTER TABLE app_user_profiles + ADD CONSTRAINT app_user_profiles_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +migrate Down +DROP TABLE IF EXISTS app_user_profiles; + diff --git a/migrations/20200428072541-create-access-tokens.sql b/migrations/20200428072541-create-access-tokens.sql new file mode 100644 index 0000000..c76f26a --- /dev/null +++ b/migrations/20200428072541-create-access-tokens.sql @@ -0,0 +1,18 @@ +-- +migrate Up +CREATE TABLE access_tokens ( + token uuid NOT NULL DEFAULT uuid_generate_v4 (), + valid_until timestamptz NOT NULL, + user_id uuid NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT access_tokens_pkey PRIMARY KEY (token) +); + +CREATE INDEX idx_access_tokens_fk_user_uid ON access_tokens USING btree (user_id); + +ALTER TABLE access_tokens + ADD CONSTRAINT access_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +migrate Down +DROP TABLE IF EXISTS access_tokens; + diff --git a/migrations/20200428072639-create-refresh-tokens.sql b/migrations/20200428072639-create-refresh-tokens.sql new file mode 100644 index 0000000..4122cd8 --- /dev/null +++ b/migrations/20200428072639-create-refresh-tokens.sql @@ -0,0 +1,17 @@ +-- +migrate Up +CREATE TABLE refresh_tokens ( + token uuid NOT NULL DEFAULT uuid_generate_v4 (), + user_id uuid NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT refresh_tokens_pkey PRIMARY KEY (token) +); + +CREATE INDEX idx_refresh_tokens_fk_user_uid ON refresh_tokens USING btree (user_id); + +ALTER TABLE refresh_tokens + ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +migrate Down +DROP TABLE IF EXISTS refresh_tokens; + diff --git a/migrations/20200428072651-create-password-reset-tokens.sql b/migrations/20200428072651-create-password-reset-tokens.sql new file mode 100644 index 0000000..09b223d --- /dev/null +++ b/migrations/20200428072651-create-password-reset-tokens.sql @@ -0,0 +1,18 @@ +-- +migrate Up +CREATE TABLE password_reset_tokens ( + token uuid NOT NULL DEFAULT uuid_generate_v4 (), + valid_until timestamptz NOT NULL, + user_id uuid NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT password_reset_tokens_pkey PRIMARY KEY (token) +); + +CREATE INDEX idx_password_reset_tokens_fk_user_uid ON password_reset_tokens USING btree (user_id); + +ALTER TABLE password_reset_tokens + ADD CONSTRAINT password_reset_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +migrate Down +DROP TABLE IF EXISTS password_reset_tokens; + diff --git a/migrations/20200529112509-create-push-token.sql b/migrations/20200529112509-create-push-token.sql new file mode 100644 index 0000000..60ca6f1 --- /dev/null +++ b/migrations/20200529112509-create-push-token.sql @@ -0,0 +1,29 @@ +-- +migrate Up +CREATE TYPE provider_type AS ENUM ( + 'fcm', + 'apn' +); + +CREATE TABLE push_tokens ( + id uuid NOT NULL DEFAULT uuid_generate_v4 (), + token text NOT NULL, + provider provider_type NOT NULL, + user_id uuid NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT push_tokens_pkey PRIMARY KEY (id), + CONSTRAINT push_tokens_token_key UNIQUE (token) +); + +CREATE INDEX idx_push_tokens_fk_user_id ON push_tokens USING btree (user_id); + +CREATE INDEX idx_push_tokens_token ON push_tokens USING btree (token); + +ALTER TABLE push_tokens + ADD CONSTRAINT push_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- +migrate Down +DROP TABLE IF EXISTS push_tokens; + +DROP TYPE IF EXISTS provider_type; + diff --git a/migrations/20200811180414-create-health-sequence.sql b/migrations/20200811180414-create-health-sequence.sql new file mode 100644 index 0000000..3bb6c40 --- /dev/null +++ b/migrations/20200811180414-create-health-sequence.sql @@ -0,0 +1,6 @@ +-- +migrate Up +CREATE SEQUENCE seq_health; + +-- +migrate Down +DROP SEQUENCE IF EXISTS seq_health; + diff --git a/migrations/20250505074511-create-table-confirmation-tokens.sql b/migrations/20250505074511-create-table-confirmation-tokens.sql new file mode 100644 index 0000000..1bd37b2 --- /dev/null +++ b/migrations/20250505074511-create-table-confirmation-tokens.sql @@ -0,0 +1,24 @@ +-- +migrate Up +CREATE TABLE confirmation_tokens ( + token uuid NOT NULL DEFAULT uuid_generate_v4 (), + valid_until timestamptz NOT NULL, + user_id uuid NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + CONSTRAINT confirmation_tokens_pkey PRIMARY KEY (token) +); + +CREATE INDEX idx_confirmation_tokens_fk_user_uid ON confirmation_tokens USING btree (user_id); + +ALTER TABLE confirmation_tokens + ADD CONSTRAINT confirmation_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE users + ADD COLUMN requires_confirmation boolean NOT NULL DEFAULT FALSE; + +-- +migrate Down +ALTER TABLE users + DROP COLUMN requires_confirmation; + +DROP TABLE IF EXISTS confirmation_tokens; + diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..1098f2a --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,6 @@ +# `/migrations` + +Our database migrations are plain SQL and managed via `sql-migrate`, see: +* https://github.com/rubenv/sql-migrate#usage +* https://github.com/rubenv/sql-migrate#writing-migrations + diff --git a/rksh b/rksh new file mode 100755 index 0000000..edfaea2 --- /dev/null +++ b/rksh @@ -0,0 +1,59 @@ +#!/bin/bash +set -Eeo pipefail + +# Description: +# Wraps passed make targets to print timing traces to stderr (if enabled). +# +# Usage: +# MAKE_TRACE_TIME=true make +# +# Requirements: +# This script must act as shell for make, thus must be configured via Makefile args (bottom of our Makefile): +# SHELL = /app/rksh +# .SHELLFLAGS = $@ +# +# Naming: +# This script was named "rksh" to convince "make" that we are still running a +# POSIX compliant shell in .ONESHELL mode. This is a hack to work around the fact +# that .ONESHELL mode only applies its "special escape handling" when it assumes +# POSIX shell mode, thus properly excapes "@-+" from the targets, which will not +# be the case when naming this file differently. +# +# See: +# https://www.gnu.org/software/make/manual/make.html#One-Shell +# https://git.savannah.gnu.org/cgit/make.git/tree/src/job.c?h=4.4.1#n433 (is_bourne_compatible_shell) +# +# Passed args: +# >&2 echo $1 # name of the make target +# >&2 echo $2 # body of the make target +# >&2 echo "make $1: /bin/bash -cEeuo pipefail $2" # debug: combined + +# colors: +RED='\033[0;31m' # errors +GREY='\033[0;90m' # trace start +GREEN='\033[0;32m' # trace end +NC='\033[0m' # No Color + +function color_reset { + >&2 printf "${NC}" # reset color +} +trap color_reset EXIT + +if [ "$MAKE_TRACE_TIME" = true ] ; then + + # Ensure to write additional information to stderr as make targets stdout may be piped + >&2 echo -e "${GREY}$(date -u +"%Y-%m-%dT%H:%M:%SZ") l${MAKELEVEL:-"*"}s${SHLVL:-"*"} RUN 'make $1' ${NC}" + + TIMEFORMAT="%Rsec" + time { + # TIMEFORMAT gets appended and printed to stderr + (/bin/bash -cEeuo pipefail "$2" \ + && (ret=$?; >&2 printf "${GREEN}$(date -u +"%Y-%m-%dT%H:%M:%SZ") l${MAKELEVEL:-"*"}s${SHLVL:-"*"} END 'make $1' exit ${ret} in ")) \ + || (ret=$?; >&2 printf "${RED}$(date -u +"%Y-%m-%dT%H:%M:%SZ") l${MAKELEVEL:-"*"}s${SHLVL:-"*"} ERR 'make $1' exit ${ret} in " && exit "${ret}") + } + +else + # Just run the make target as usual while highlighting errors + (/bin/bash -cEeuo pipefail "$2") \ + || (ret=$?; >&2 echo -e "${RED}l${MAKELEVEL:-"*"}s${SHLVL:-"*"} ERR 'make $1' exit ${ret}${NC}" && exit "${ret}") +fi \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..3a7e8c9 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,42 @@ +# `/scripts` + +Scripts to perform various build, install, analysis, etc operations. + +These scripts keep the root level Makefile small and simple. + +https://github.com/golang-standards/project-layout/tree/master/scripts + +Examples: + +* https://github.com/kubernetes/helm/tree/master/scripts +* https://github.com/cockroachdb/cockroach/tree/master/scripts +* https://github.com/hashicorp/terraform/tree/master/scripts + +Please note that this scripts are not available in a final product. Head to `../cmd` if you need to execute your script in live environments. + +The `gsdev` cli util executes this `scripts/main.go` file here and also describes all available commands available while developing a project locally. `gsdev` is made available during the `Dockerfile`'s development stage. + +### `/scripts/cmd/*.go` + +`func`s may define shared logic used in `/scripts/internal/**/*.go`, the actual usable commands are defined within `/scripts/internal`. + +### `//go:build scripts` + +Any `*.go` file in all subdirectories of `/scripts/**` should specify `//go:build scripts` to signal that those files are not part of of our final product. To execute any script that has this build tag, you need to specify `-tags scripts`, otherwise you will run into an error like the following (also see our `Makefile` for a reference): + +```bash +# Works +go run -tags scripts scripts/main.go +# go-starter development scripts +# Utility commands while developing go-starter based projects. + +# Works (same as above) +gsdev +# go-starter development scripts +# Utility commands while developing go-starter based projects. + +# Misses build tag "scripts" +go run scripts/main.go +# package command-line-arguments +# imports allaboutapps.dev/aw/go-starter/scripts/cmd: build constraints exclude all Go files in /app/scripts/cmd +``` diff --git a/scripts/cmd/handlers.go b/scripts/cmd/handlers.go new file mode 100644 index 0000000..dfcea7a --- /dev/null +++ b/scripts/cmd/handlers.go @@ -0,0 +1,29 @@ +//go:build scripts + +package cmd + +import ( + "os" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// handlersCmd represents the handlers command +// see handlers_*.go for sub_commands +var handlersCmd = &cobra.Command{ + Use: "handlers ", + Short: "Handlers related subcommands", + Run: func(cmd *cobra.Command, _ []string /* args */) { + if err := cmd.Help(); err != nil { + log.Error().Err(err).Msg("Failed to print help") + os.Exit(1) + } + os.Exit(0) + }, +} + +//nolint:gochecknoinits +func init() { + rootCmd.AddCommand(handlersCmd) +} diff --git a/scripts/cmd/handlers_check.go b/scripts/cmd/handlers_check.go new file mode 100644 index 0000000..bce2989 --- /dev/null +++ b/scripts/cmd/handlers_check.go @@ -0,0 +1,37 @@ +//go:build scripts + +package cmd + +import ( + "log" + + "allaboutapps.dev/aw/go-starter/scripts/internal/handlers" + "github.com/spf13/cobra" +) + +const ( + printAllFlag = "print-all" +) + +var handlersCheckCmd = &cobra.Command{ + Use: "check", + Short: "Checks handlers vs. swagger spec.", + Long: `Checks currently implemented handlers against swagger spec.`, + Run: func(cmd *cobra.Command, _ []string /* args */) { + + printAll, err := cmd.Flags().GetBool(printAllFlag) + if err != nil { + log.Fatal(err) + } + err = handlers.CheckHandlers(printAll) + if err != nil { + log.Fatal(err) + } + }, +} + +//nolint:gochecknoinits +func init() { + handlersCmd.AddCommand(handlersCheckCmd) + handlersCheckCmd.Flags().Bool(printAllFlag, false, "Print only print the current implemented handlers, do not generate the file.") +} diff --git a/scripts/cmd/handlers_gen.go b/scripts/cmd/handlers_gen.go new file mode 100644 index 0000000..bab5d34 --- /dev/null +++ b/scripts/cmd/handlers_gen.go @@ -0,0 +1,38 @@ +//go:build scripts + +package cmd + +import ( + "log" + + "allaboutapps.dev/aw/go-starter/scripts/internal/handlers" + "github.com/spf13/cobra" +) + +const ( + printOnlyFlag = "print-only" +) + +var handlersGenCmd = &cobra.Command{ + Use: "gen", + Short: "Generate internal/api/handlers/handlers.go.", + Long: `Generates internal/api/handlers/handlers.go file based on the current implemented handlers.`, + Run: func(cmd *cobra.Command, _ []string /* args */) { + + printOnly, err := cmd.Flags().GetBool(printOnlyFlag) + if err != nil { + log.Fatal(err) + } + + err = handlers.GenHandlers(printOnly) + if err != nil { + log.Fatal(err) + } + }, +} + +//nolint:gochecknoinits +func init() { + handlersCmd.AddCommand(handlersGenCmd) + handlersGenCmd.Flags().Bool(printOnlyFlag, false, "Print all checked handlers regardless of errors.") +} diff --git a/scripts/cmd/modulename.go b/scripts/cmd/modulename.go new file mode 100644 index 0000000..ec53135 --- /dev/null +++ b/scripts/cmd/modulename.go @@ -0,0 +1,34 @@ +//go:build scripts + +package cmd + +import ( + "allaboutapps.dev/aw/go-starter/scripts/internal/util" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// moduleCmd represents the server command +var moduleCmd = &cobra.Command{ + Use: "modulename", + Short: "Prints the modulename", + Long: `Prints the currently applied go modulename of this project.`, + Run: func(_ *cobra.Command /* cmd */, _ []string /* args */) { + runModulename() + }, +} + +//nolint:gochecknoinits +func init() { + rootCmd.AddCommand(moduleCmd) +} + +func runModulename() { + baseModuleName, err := util.GetModuleName(modulePath) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to get module name") + } + + log.Info().Msg(baseModuleName) +} diff --git a/scripts/cmd/root.go b/scripts/cmd/root.go new file mode 100644 index 0000000..82605e3 --- /dev/null +++ b/scripts/cmd/root.go @@ -0,0 +1,34 @@ +//go:build scripts + +package cmd + +import ( + "os" + "path/filepath" + + "allaboutapps.dev/aw/go-starter/scripts/internal/util" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var ( + projectRoot = util.GetProjectRootDir() + modulePath = filepath.Join(util.GetProjectRootDir(), "go.mod") +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "gsdev", + Short: "gsdev", + Long: `go-starter development scripts +Utility commands while developing go-starter based projects.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Error().Err(err).Msg("Failed to execute root command") + os.Exit(1) + } +} diff --git a/scripts/cmd/scaffold.go b/scripts/cmd/scaffold.go new file mode 100644 index 0000000..29daa81 --- /dev/null +++ b/scripts/cmd/scaffold.go @@ -0,0 +1,86 @@ +//go:build scripts + +package cmd + +import ( + "log" + "os/exec" + "path/filepath" + + "allaboutapps.dev/aw/go-starter/scripts/internal/scaffold" + "github.com/spf13/cobra" +) + +const ( + methodsFlag = "methods" + forceFlag = "force" +) + +var ( + modelPath = filepath.Join(projectRoot, "internal/models") + swaggerPath = filepath.Join(projectRoot, "api") + handlerPath = filepath.Join(projectRoot, "internal/api/handlers") + + makeSwaggerCmd = exec.Command("make", "swagger") + makeGoGenerateCmd = exec.Command("make", "go-generate") + + defaultMethods = []string{"get-all", "get", "post", "put", "delete"} +) + +// rootCmd represents the base command when called without any subcommands +var scaffoldCmd = &cobra.Command{ + Use: "scaffold [resource name]", + Short: "Scaffolding tool for CRUD endpoints.", + Long: "Scaffolding tool to generate CRUD endpoint stubs from sqlboiler model definitions.", + Run: generate, +} + +//nolint:gochecknoinits +func init() { + rootCmd.AddCommand(scaffoldCmd) + scaffoldCmd.Flags().StringSliceP(methodsFlag, "m", defaultMethods, "Specify HTTP methods to generate handlers for. Example: scaffold --methods get-all,get,delete") + scaffoldCmd.Flags().BoolP(forceFlag, "f", false, "Forces the tool to overwrite existing files.") +} + +func generate(cmd *cobra.Command, args []string) { + if len(args) < 1 { + log.Fatal("Please provide a valid resource name") + } + + resourceName := args[0] + if resourceName == "" { + log.Fatal("Please provide a valid resource name") + } + + methods, err := cmd.Flags().GetStringSlice(methodsFlag) + if err != nil { + log.Fatalf("Failed to read %s: %v", methodsFlag, err) + } + + force, err := cmd.Flags().GetBool(forceFlag) + if err != nil { + log.Fatalf("Failed to read %s: %v", forceFlag, err) + } + + resourcePath := filepath.Join(modelPath, resourceName+".go") + resource, err := scaffold.ParseModel(resourcePath) + if err != nil { + log.Fatalf("Failed to parse resource from model file: %v", err) + } + + if err = scaffold.GenerateSwagger(resource, swaggerPath, force); err != nil { + log.Fatalf("Failed to generate Swagger spec: %v", err) + } + + if err = scaffold.GenerateHandlers(resource, handlerPath, modulePath, methods, force); err != nil { + log.Fatalf("Failed to generate handlers: %v", err) + } + + if err = makeSwaggerCmd.Run(); err != nil { + log.Fatalf("Failed to run 'make swagger': %v", err) + } + + if err = makeGoGenerateCmd.Run(); err != nil { + log.Fatalf("Failed to run 'make go-generate': %v", err) + } +} diff --git a/scripts/internal/handlers/check.go b/scripts/internal/handlers/check.go new file mode 100644 index 0000000..34bd05f --- /dev/null +++ b/scripts/internal/handlers/check.go @@ -0,0 +1,120 @@ +//go:build scripts + +// This program checks /internal/api/handlers.go and /internal/types/spec_handlers.go +// It can be invoked by running go run -tags scripts scripts/handlers/check_handlers.go + +// Supported args: +// --print-all + +package handlers + +import ( + "context" + "fmt" + "strings" + "sync" + + "allaboutapps.dev/aw/go-starter/internal/api" + "allaboutapps.dev/aw/go-starter/internal/api/router" + "allaboutapps.dev/aw/go-starter/internal/config" + "allaboutapps.dev/aw/go-starter/internal/types" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func CheckHandlers(printAll bool) error { + // we initialize a minimal echo server without any other deps + // so we can attach the current defined routes and read them + log.Logger = log.Output(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.TimeFormat = "15:04:05" + })) + + defaultConfig := config.DefaultServiceConfigFromEnv() + defaultConfig.Echo.ListenAddress = ":0" + + s := api.NewServer(defaultConfig) + err := router.Init(s) + if err != nil { + return fmt.Errorf("failed to initialize router: %w", err) + } + + // swaggerspec vs routes + routes := s.Router.Routes + swaggerSpec := types.NewSwaggerSpec() + + var wg sync.WaitGroup + + for _, route := range routes { + wg.Add(1) + + go func() { + defer wg.Done() + + // replace named echo ":param" to swagger path params "{param}" (curly braces) to properly match paths + fragments := strings.Split(route.Path, "/") + + for i, fragment := range fragments { + if strings.HasPrefix(fragment, ":") { + fragments[i] = "{" + strings.TrimLeft(fragment, ":") + "}" + } + } + + swaggerPath := strings.Join(fragments, "/") + + ok := swaggerSpec.Handlers[route.Method][swaggerPath] + + if !ok { + log.Warn().Msgf("%s %s\n WARNING: Missing swagger spec in api/swagger.yml!", route.Method, route.Path) + } else if printAll { + log.Info().Msgf("%s %s", route.Method, route.Path) + } + }() + } + + for method, v := range swaggerSpec.Handlers { + for path := range v { + // NOTE: https://github.com/golang/go/wiki/CommonMistakes#using-goroutines-on-loop-iterator-variables + ttMethod := method + ttPath := path + + wg.Add(1) + + go func() { + defer wg.Done() + + // replace named swagger path params "{param}" to echo ":param" (dotted) to properly match paths + fragments := strings.Split(ttPath, "/") + + for i, fragment := range fragments { + if strings.HasPrefix(fragment, "{") && strings.HasSuffix(fragment, "}") { + fragments[i] = ":" + strings.TrimLeft(strings.TrimRight(fragment, "}"), "{") + } + } + + echoPath := strings.Join(fragments, "/") + + hasMatch := false + + for _, route := range routes { + if route.Method == ttMethod && route.Path == echoPath { + hasMatch = true + break + } + } + + if !hasMatch { + log.Warn().Msgf("%s %s\n WARNING: Missing route implementation in internal/api/handlers/*!", ttMethod, echoPath) + } + }() + } + } + + // we have our routes, the server is no longer needed. + if errs := s.Shutdown(context.Background()); len(errs) > 0 { + log.Error().Errs("shutdownErrors", errs).Msg("Failed to stop introspection server") + } + + wg.Wait() + + return nil +} diff --git a/scripts/internal/handlers/gen.go b/scripts/internal/handlers/gen.go new file mode 100644 index 0000000..6a8d618 --- /dev/null +++ b/scripts/internal/handlers/gen.go @@ -0,0 +1,183 @@ +//go:build scripts + +// This program generates /internal/api/handlers.go. +// It can be invoked by running go run -tags scripts scripts/handlers/gen_handlers.go + +// Supported args: +// -print-only + +package handlers + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "text/template" + + "github.com/rs/zerolog/log" + + "allaboutapps.dev/aw/go-starter/scripts/internal/util" +) + +// https://blog.carlmjohnson.net/post/2016-11-27-how-to-use-go-generate/ + +var ( + handlersPackage = "/internal/api/handlers" + + pathProjectRoot = util.GetProjectRootDir() + pathHandlersRoot = pathProjectRoot + handlersPackage + pathModFile = pathProjectRoot + "/go.mod" + pathHandlersFile = pathHandlersRoot + "/handlers.go" + + // methodPrefixes defines all keywords that we search for in the handlers sub packages + // * must be the naming of the route (Capitalized) + methodPrefixes = []string{ + // https://swagger.io/specification/v2/ fixed fields: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH + "Get", "Put", "Post", "Delete", "Options", "Head", "Patch", + } + // Match suffixes like 'Route', 'RouteV1', 'RouteV123' + methodSuffixPattern = regexp.MustCompile(`Route(V[0-9]*)?$`) + + packageTemplate = template.Must(template.New("").Parse(`// Code generated by go run -tags scripts scripts/handlers/gen_handlers.go; DO NOT EDIT. +package handlers + +import ( + "allaboutapps.dev/aw/go-starter/internal/api" + {{- range .SubPkgs }} + "{{ . }}" + {{- end }} + "github.com/labstack/echo/v4" +) + +func AttachAllRoutes(s *api.Server) { + // attach our routes + s.Router.Routes = []*echo.Route{ + {{- range .Funcs }} + {{ .PackageName }}.{{ .FunctionName }}(s), + {{- end }} + } +} +`)) +) + +type ResolvedFunction struct { + FunctionName string + PackageName string + PackageNameFQDN string +} + +type TempateData struct { + SubPkgs []string + Funcs []ResolvedFunction +} + +// get all functions in above handler packages +// that match * +func GenHandlers(printOnly bool) error { + funcs := []ResolvedFunction{} + + baseModuleName, err := util.GetModuleName(pathModFile) + if err != nil { + log.Fatal().Err(err).Msg("Failed to get module name") + } + + set := token.NewFileSet() + + err = filepath.Walk(pathHandlersRoot, func(path string, fileInfo os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("failed to access path %q: %w", path, err) + } + + // ignore handler file to be generated + // ignore directories + // ignore non go files + // ignore test go files + if path == pathHandlersFile || fileInfo.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "test.go") { + return nil + } + + gofile, err := parser.ParseFile(set, path, nil, 0) + if err != nil { + return fmt.Errorf("failed to parse package: %q: %w", path, err) + } + + fileDir := filepath.Dir(path) + packageNameFQDN := strings.Replace(fileDir, pathProjectRoot, baseModuleName, 1) + + for _, d := range gofile.Decls { + if fn, isFn := d.(*ast.FuncDecl); isFn { + fnName := fn.Name.String() + + for _, prefix := range methodPrefixes { + if strings.HasPrefix(fnName, prefix) && methodSuffixPattern.MatchString(fnName) { + funcs = append(funcs, ResolvedFunction{ + FunctionName: fnName, + PackageName: gofile.Name.Name, + PackageNameFQDN: packageNameFQDN, + }) + } + } + } + } + + // fmt.Println(packageNameFQDN, path) + + return nil + }) + if err != nil { + return fmt.Errorf("failed to walk handlers root: %w", err) + } + + // stable sort the functions + // first PackageName then FunctionName + sort.Slice(funcs, func(i, j int) bool { + return funcs[i].PackageNameFQDN+funcs[i].FunctionName < funcs[j].PackageNameFQDN+funcs[j].FunctionName + }) + + // only add subPkg if there was actually a route within it found + subPkgs := []string{} + for _, fun := range funcs { + mustAppend := true + + for _, a := range subPkgs { + if a == fun.PackageNameFQDN { + mustAppend = false + } + } + + if mustAppend { + subPkgs = append(subPkgs, fun.PackageNameFQDN) + } + } + + if printOnly { + for _, function := range funcs { + log.Info().Msgf("%s %s", function.PackageNameFQDN, function.FunctionName) + } + + // bailout + return nil + } + + handlersFile, err := os.Create(pathHandlersFile) + if err != nil { + return fmt.Errorf("failed to create handlers file: %w", err) + } + + defer handlersFile.Close() + + if err = packageTemplate.Execute(handlersFile, TempateData{ + SubPkgs: subPkgs, + Funcs: funcs, + }); err != nil { + return fmt.Errorf("failed to execute package template: %w", err) + } + + return nil +} diff --git a/scripts/internal/scaffold/scaffold.go b/scripts/internal/scaffold/scaffold.go new file mode 100644 index 0000000..97e3a9a --- /dev/null +++ b/scripts/internal/scaffold/scaffold.go @@ -0,0 +1,367 @@ +//go:build scripts + +package scaffold + +import ( + "fmt" + "go/ast" + "go/parser" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "text/template" + + "github.com/go-openapi/swag" + "golang.org/x/mod/modfile" +) + +// Scaffolding tool to auto-generate basic CRUD handlers for a given database model. + +type FieldType struct { + Name string +} + +type Field struct { + Name string + Type FieldType +} + +type StorageResource struct { + Name string + Fields []Field +} + +func ParseModel(path string) (*StorageResource, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read model file: %w", err) + } + + regex, err := regexp.Compile(`type ([^\s]+) (struct {[^}]+})`) + if err != nil { + return nil, fmt.Errorf("failed to compile model expression regex: %w", err) + } + + matches := regex.FindStringSubmatch(string(content)) + resourceName := matches[1] + expression := matches[2] + + node, err := parser.ParseExpr(expression) + if err != nil { + return nil, fmt.Errorf("failed to parse model expression: %w", err) + } + + structType, ok := node.(*ast.StructType) + if !ok { + return nil, fmt.Errorf("%s is not a struct definition", expression) + } + + fields := []Field{} + for _, field := range structType.Fields.List { + name := field.Names[0].Name + fieldType := expression[field.Type.Pos()-1 : field.Type.End()-1] + field := Field{ + Name: name, + Type: FieldType{ + Name: fieldType, + }, + } + + if field.Name == "R" || field.Name == "L" { + break + } + + fields = append(fields, field) + } + + resource := StorageResource{ + Name: resourceName, + Fields: fields, + } + + return &resource, nil +} + +type Property struct { + Name string + Type string + Required bool + Format *string +} + +type SwaggerResource struct { + Name string + URLName string + Properties []Property + PayloadProperties []Property +} + +func GenerateSwagger(resource *StorageResource, outputPath string, force bool) error { + definitionsPath := filepath.Join(outputPath, "definitions") + pathsPath := filepath.Join(outputPath, "paths") + if err := createDirIfAbsent(definitionsPath); err != nil { + return err + } + if err := createDirIfAbsent(pathsPath); err != nil { + return err + } + + swaggerResource := toSwaggerResource(resource) + definitionsSpecPath := filepath.Join(definitionsPath, swaggerResource.URLName+".yml") + pathsSpecPath := filepath.Join(pathsPath, swaggerResource.URLName+".yml") + + if err := executeTemplate(swaggerDefinitionsTemplate, definitionsSpecPath, swaggerResource, force); err != nil { + return err + } + + return executeTemplate(swaggerPathsTemplate, pathsSpecPath, swaggerResource, force) +} + +var payloadExcluded = []string{"ID", "CreatedAt", "UpdatedAt"} + +func toSwaggerResource(resource *StorageResource) *SwaggerResource { + properties := make([]Property, 0, len(resource.Fields)) + payloadProperties := make([]Property, 0, len(resource.Fields)) + for _, field := range resource.Fields { + property := fieldToProperty(field) + properties = append(properties, property) + + if !slices.Contains(payloadExcluded, field.Name) { + payloadProperties = append(payloadProperties, property) + } + } + + swaggerResource := SwaggerResource{ + Name: resource.Name, + URLName: strings.ToLower(resource.Name), // TODO: Use dash separator + Properties: properties, + PayloadProperties: payloadProperties, + } + + return &swaggerResource +} + +const ( + propertyTypeString = "string" + propertyTypeInteger = "integer" + propertyTypeBoolean = "boolean" + propertyTypeNumber = "number" + propertyTypeDateTime = "date-time" + propertyTypeUUID4 = "uuid4" +) + +func fieldToProperty(field Field) Property { + propertyType := propertyTypeString // Fallback type + required := true + var format *string + + switch field.Type.Name { + case "int": + propertyType = propertyTypeInteger + case "null.Int": + propertyType = propertyTypeInteger + required = false + case "bool": + propertyType = propertyTypeBoolean + case "null.Bool": + propertyType = propertyTypeBoolean + required = false + case "null.String": + propertyType = propertyTypeString + required = false + case "types.Decimal": + propertyType = propertyTypeNumber + case "types.NullDecimal": + propertyType = propertyTypeNumber + required = false + case "time.Time": + format = swag.String(propertyTypeDateTime) + case "null.Time": + format = swag.String(propertyTypeDateTime) + required = false + } + + if strings.Contains(field.Name, "ID") { + format = swag.String(propertyTypeUUID4) + } + + return Property{ + Name: goToJSNaming(field.Name), + Type: propertyType, + Required: required, + Format: format, + } +} + +type HandlerField struct { + Name string + Value string + PlaceholderValue string +} + +type HandlerResource struct { + Name string + Fields []HandlerField +} + +type Handler struct { + Module string + Package string + Resource *HandlerResource +} + +func toHandlerResource(storageResource *StorageResource, swaggerResource *SwaggerResource) *HandlerResource { + fields := make([]HandlerField, len(swaggerResource.Properties)) + for i, property := range swaggerResource.Properties { + fields[i] = propertyToHandlerField(property) + + // Hack to get the proper field name. + fields[i].Name = storageResource.Fields[i].Name + } + + return &HandlerResource{ + Name: swaggerResource.Name, + Fields: fields, + } +} + +func propertyToHandlerField(property Property) HandlerField { + placeholderValue := `swag.String("` + property.Name + `")` // Fallback placeholder + + switch property.Type { + case "integer": + placeholderValue = `swag.Int64(100)` + case "boolean": + placeholderValue = `swag.Bool(true)` + case "number": + placeholderValue = `swag.Float64(10.0)` + } + + if property.Format != nil { + switch *property.Format { + case "date-time": + placeholderValue = `conv.DateTime(strfmt.DateTime(time.Now()))` + + case "uuid4": + placeholderValue = `conv.UUID4(strfmt.UUID4("1606388b-1f88-4f56-bd97-c27fbc3fe080"))` + } + } + + return HandlerField{ + Name: property.Name, + PlaceholderValue: placeholderValue, + } +} + +type handlerConfig struct { + filePrefix string + fileSuffix string + template string +} + +var configuredHandlers = map[string]handlerConfig{ + "get-all": {"get_", "_list.go", getListHandlerTemplate}, + "get": {"get_", ".go", getHandlerTemplate}, + "post": {"post_", ".go", postHandlerTemplate}, + "put": {"put_", ".go", putHandlerTemplate}, + "delete": {"delete_", ".go", deleteHandlerTemplate}, +} + +func GenerateHandlers(resource *StorageResource, handlerBaseDir, modulePath string, methods []string, force bool) error { + packageName := strings.ToLower(resource.Name) + resourceBaseDir := filepath.Join(handlerBaseDir, packageName) + + if _, err := os.Stat(resourceBaseDir); os.IsNotExist(err) { + if err := os.Mkdir(resourceBaseDir, 0755); err != nil { + return fmt.Errorf("failed to create resource base directory: %w", err) + } + } + + module, err := getModuleName(modulePath) + if err != nil { + return err + } + + handler := Handler{ + Module: module, + Package: packageName, + Resource: toHandlerResource(resource, toSwaggerResource(resource)), + } + + for _, method := range methods { + handlerConfig, ok := configuredHandlers[method] + if !ok { + return fmt.Errorf("unsupported method: %s", method) + } + + outputPath := filepath.Join(resourceBaseDir, handlerConfig.filePrefix+packageName+handlerConfig.fileSuffix) + if err := executeTemplate(handlerConfig.template, outputPath, handler, force); err != nil { + return err + } + } + + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} + +func createDirIfAbsent(path string) error { + if !fileExists(path) { + if err := os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create directory '%s': %w", path, err) + } + } + + return nil +} + +func executeTemplate(templateStr, outputPath string, data interface{}, force bool) error { + templ := template.Template{} + if _, err := templ.Parse(templateStr); err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + if !force && fileExists(outputPath) { + return fmt.Errorf("file '%s' already exists; call with --force to overwrite", outputPath) + } + + file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open file '%s': %w", outputPath, err) + } + defer file.Close() + + if err := templ.Execute(file, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} + +func goToJSNaming(name string) string { + // Hack to properly handle ID. + switch name { + case "ID": + return "id" + default: + first := strings.ToLower(name[0:1]) + return first + name[1:] + } +} + +func getModuleName(absolutePathToGoMod string) (string, error) { + dat, err := os.ReadFile(absolutePathToGoMod) + + if err != nil { + return "", fmt.Errorf("failed to read go.mod: %w", err) + } + + mod := modfile.ModulePath(dat) + + return mod, nil +} diff --git a/scripts/internal/scaffold/scaffold_test.go b/scripts/internal/scaffold/scaffold_test.go new file mode 100644 index 0000000..2ca4f32 --- /dev/null +++ b/scripts/internal/scaffold/scaffold_test.go @@ -0,0 +1,86 @@ +//go:build scripts + +package scaffold_test + +import ( + "os" + "path/filepath" + "testing" + + "allaboutapps.dev/aw/go-starter/internal/test" + "allaboutapps.dev/aw/go-starter/internal/util" + "allaboutapps.dev/aw/go-starter/scripts/internal/scaffold" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + resourcePath = "testdata/test_resource.txt" + definitionsPath = "testdata/definitions" + pathsPath = "testdata/paths" + handlerPath = "testdata/testresource" + modulePath = filepath.Join(util.GetProjectRootDir(), "go.mod") + + defaultMethods = []string{"get-all", "get", "post", "put", "delete"} +) + +func TestParseModel_Success(t *testing.T) { + // Execute + resource, err := scaffold.ParseModel(resourcePath) + + // Assert + require.NoError(t, err) + test.Snapshoter.Save(t, resource) +} + +func TestGenerateSwagger_Success(t *testing.T) { + // Setup + resource, err := scaffold.ParseModel(resourcePath) + require.NoError(t, err) + + // Execute + err = scaffold.GenerateSwagger(resource, "testdata", true) + + // Assert + require.NoError(t, err) + + assert.DirExists(t, definitionsPath, "Should create the definitions directory") + assert.DirExists(t, pathsPath, "Should create the paths directory") + assert.FileExists(t, filepath.Join(definitionsPath, "testresource.yml"), "Should generate the definition spec") + assert.FileExists(t, filepath.Join(pathsPath, "testresource.yml"), "Should generate the paths spec") + + // Cleanup + err = os.RemoveAll(definitionsPath) + require.NoError(t, err) + err = os.RemoveAll(pathsPath) + require.NoError(t, err) +} + +func TestGenerateHandlers_Success(t *testing.T) { + // Setup + resource, err := scaffold.ParseModel(resourcePath) + require.NoError(t, err) + err = scaffold.GenerateSwagger(resource, "testdata", true) + require.NoError(t, err) + + // Execute + err = scaffold.GenerateHandlers(resource, "testdata", modulePath, defaultMethods, true) + + // Assert + require.NoError(t, err) + + assert.DirExists(t, handlerPath, "Should create the handler directory") + assert.FileExists(t, filepath.Join(handlerPath, "get_testresource_list.go"), "Should create the GET list handler") + assert.FileExists(t, filepath.Join(handlerPath, "get_testresource.go"), "Should create the GET handler") + assert.FileExists(t, filepath.Join(handlerPath, "post_testresource.go"), "Should create the POST handler") + assert.FileExists(t, filepath.Join(handlerPath, "put_testresource.go"), "Should create the PUT handler") + assert.FileExists(t, filepath.Join(handlerPath, "delete_testresource.go"), "Should create the DELETE handler") + + // Cleanup + err = os.RemoveAll(definitionsPath) + require.NoError(t, err) + err = os.RemoveAll(pathsPath) + require.NoError(t, err) + err = os.RemoveAll(handlerPath) + require.NoError(t, err) +} diff --git a/scripts/internal/scaffold/templates.go b/scripts/internal/scaffold/templates.go new file mode 100644 index 0000000..9abe81a --- /dev/null +++ b/scripts/internal/scaffold/templates.go @@ -0,0 +1,366 @@ +//go:build scripts + +package scaffold + +const ( + swaggerDefinitionsTemplate = `swagger: "2.0" +info: + title: "" + version: 0.1.0 +paths: {} +definitions: + {{ .Name }}: + type: object + required: + {{- range .Properties}} + {{- if .Required }} + - {{ .Name }} + {{- end }} + {{- end }} + properties: + {{- range .Properties}} + {{ .Name }}: + type: {{ .Type }} + {{- if not .Required }} + x-nullable: true + {{- end }} + {{- if .Format }} + format: {{ .Format }} + {{- end }} + {{- end }} + + {{ .Name }}Payload: + type: object + required: + {{- range .PayloadProperties}} + {{- if .Required }} + - {{ .Name }} + {{- end }} + {{- end }} + properties: + {{- range .PayloadProperties}} + {{ .Name }}: + type: {{ .Type }} + {{- if not .Required }} + x-nullable: true + {{- end }} + {{- if .Format }} + format: {{ .Format }} + {{- end }} + {{- end }} + + {{ .Name }}List: + type: array + items: + $ref: "#/definitions/{{ .Name }}" +` + + swaggerPathsTemplate = `swagger: "2.0" +info: + title: "" + version: 0.1.0 +parameters: + {{ .Name }}IdParam: + type: string + format: uuid4 + name: id + description: ID of {{ .Name }} + in: path + required: true + +paths: + /api/v1/{{ .URLName }}s: + get: + security: + - Bearer: [] + description: "Return a list of {{ .Name }}" + tags: + - {{ .Name }} + summary: "Return a list of {{ .Name }}" + operationId: Get{{ .Name }}ListRoute + responses: + "200": + description: Success + schema: + $ref: "../definitions/{{ .URLName }}.yml#/definitions/{{ .Name }}List" + + post: + security: + - Bearer: [] + description: "Update the given {{ .Name }}" + tags: + - {{ .Name }} + summary: "Update the given {{ .Name }}" + operationId: Post{{ .Name }}Route + parameters: + - name: Payload + in: body + schema: + $ref: "../definitions/{{ .URLName }}.yml#/definitions/{{ .Name }}Payload" + responses: + "200": + description: Success + schema: + $ref: "../definitions/{{ .URLName }}.yml#/definitions/{{ .Name }}" + + /api/v1/{{ .URLName }}s/{id}: + get: + security: + - Bearer: [] + description: "Return {{ .Name }} with ID" + tags: + - {{ .Name }} + summary: "Return {{ .Name }} with ID" + operationId: Get{{ .Name }}Route + parameters: + - $ref: "#/parameters/{{ .Name }}IdParam" + responses: + "200": + description: Success + schema: + $ref: "../definitions/{{ .URLName }}.yml#/definitions/{{ .Name }}" + + put: + security: + - Bearer: [] + description: "Update the given {{ .Name }}" + tags: + - {{ .Name }} + summary: "Update the given {{ .Name }}" + operationId: Put{{ .Name }}Route + parameters: + - $ref: "#/parameters/{{ .Name }}IdParam" + - name: Payload + in: body + schema: + $ref: "../definitions/{{ .URLName }}.yml#/definitions/{{ .Name }}Payload" + responses: + "200": + description: Success + schema: + $ref: "../definitions/{{ .URLName }}.yml#/definitions/{{ .Name }}" + + delete: + security: + - Bearer: [] + description: "Delete {{ .Name }} with ID" + tags: + - {{ .Name }} + summary: "Delete {{ .Name }} with ID" + operationId: Delete{{ .Name }}Route + parameters: + - $ref: "#/parameters/{{ .Name }}IdParam" + responses: + "204": + description: Success +` + + getHandlerTemplate = `package {{ .Package }} + +import ( + "net/http" + "time" + + "{{ .Module }}/internal/api" + "{{ .Module }}/internal/types" + "{{ .Module }}/internal/util" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func Get{{ .Resource.Name }}Route(s *api.Server) *echo.Route { + return s.Router.APIV1{{ .Resource.Name }}.GET("/:id", get{{ .Resource.Name }}Handler(s)) +} + +func get{{ .Resource.Name }}Handler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + /* Uncomment for real implementation + ctx := c.Request().Context() + + params := {{ .Package }}.NewGet{{ .Resource.Name }}RouteParams() + err := util.BindAndValidatePathParams(c, ¶ms) + if err != nil { + return err + } + id := params.ID.String() + + // TODO: Implement + */ + + response := types.{{ .Resource.Name }}{ + {{- range .Resource.Fields }} + {{ .Name }}: {{ .PlaceholderValue }}, + {{- end }} + } + + return util.ValidateAndReturn(c, http.StatusOK, &response) + } +} +` + + getListHandlerTemplate = `package {{ .Package }} + +import ( + "net/http" + "time" + + "{{ .Module }}/internal/api" + "{{ .Module }}/internal/types" + "{{ .Module }}/internal/util" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func Get{{ .Resource.Name }}ListRoute(s *api.Server) *echo.Route { + return s.Router.APIV1{{ .Resource.Name }}.GET("", get{{ .Resource.Name }}ListHandler(s)) +} + +func get{{ .Resource.Name }}ListHandler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + /* Uncomment for real implementation + ctx := c.Request().Context() + + // TODO: Implement + */ + + item := types.{{ .Resource.Name }}{ + {{- range .Resource.Fields }} + {{ .Name }}: {{ .PlaceholderValue }}, + {{- end }} + } + response := types.{{ .Resource.Name }}List{&item} + + return util.ValidateAndReturn(c, http.StatusOK, &response) + } +} +` + postHandlerTemplate = `package {{ .Package }} + +import ( + "net/http" + "time" + + "{{ .Module }}/internal/api" + "{{ .Module }}/internal/types" + "{{ .Module }}/internal/util" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func Post{{ .Resource.Name }}Route(s *api.Server) *echo.Route { + return s.Router.APIV1{{ .Resource.Name }}.POST("", post{{ .Resource.Name }}Handler(s)) +} + +func post{{ .Resource.Name }}Handler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + /* Uncomment for real implementation + ctx := c.Request().Context() + + var body types.{{ .Resource.Name }}Payload + err := util.BindAndValidateBody(c, &body) + if err != nil { + return err + } + + // TODO: Implement + */ + + response := types.{{ .Resource.Name }}{ + {{- range .Resource.Fields }} + {{ .Name }}: {{ .PlaceholderValue }}, + {{- end }} + } + + return util.ValidateAndReturn(c, http.StatusOK, &response) + } +} +` + putHandlerTemplate = `package {{ .Package }} + +import ( + "net/http" + "time" + + "{{ .Module }}/internal/api" + "{{ .Module }}/internal/types" + "{{ .Module }}/internal/util" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" + "github.com/go-openapi/swag" + "github.com/labstack/echo/v4" +) + +func Put{{ .Resource.Name }}Route(s *api.Server) *echo.Route { + return s.Router.APIV1{{ .Resource.Name }}.PUT("/:id", put{{ .Resource.Name }}Handler(s)) +} + +func put{{ .Resource.Name }}Handler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + /* Uncomment for real implementation + ctx := c.Request().Context() + + params := {{ .Package }}.NewGet{{ .Resource.Name }}RouteParams() + err := util.BindAndValidatePathParams(c, ¶ms) + if err != nil { + return err + } + id := params.ID.String() + + var body types.{{ .Resource.Name }}Payload + err = util.BindAndValidateBody(c, &body) + if err != nil { + return err + } + + // TODO: Implement + */ + + response := types.{{ .Resource.Name }}{ + {{- range .Resource.Fields }} + {{ .Name }}: {{ .PlaceholderValue }}, + {{- end }} + } + + return util.ValidateAndReturn(c, http.StatusOK, &response) + } +} +` + deleteHandlerTemplate = `package {{ .Package }} + +import ( + "net/http" + + "{{ .Module }}/internal/api" + "github.com/labstack/echo/v4" +) + +func Delete{{ .Resource.Name }}Route(s *api.Server) *echo.Route { + return s.Router.APIV1{{ .Resource.Name }}.DELETE("/:id", delete{{ .Resource.Name }}Handler(s)) +} + +func delete{{ .Resource.Name }}Handler(s *api.Server) echo.HandlerFunc { + return func(c echo.Context) error { + /* Uncomment for real implementation + ctx := c.Request().Context() + + params := {{ .Package }}.NewGet{{ .Resource.Name }}RouteParams() + err := util.BindAndValidatePathParams(c, ¶ms) + if err != nil { + return err + } + id := params.ID.String() + + // TODO: Implement + */ + + return c.NoContent(http.StatusNoContent) + } +} +` +) diff --git a/scripts/internal/scaffold/testdata/test_resource.txt b/scripts/internal/scaffold/testdata/test_resource.txt new file mode 100644 index 0000000..5f6a29d --- /dev/null +++ b/scripts/internal/scaffold/testdata/test_resource.txt @@ -0,0 +1,758 @@ +// Code generated by SQLBoiler 4.5.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/friendsofgo/errors" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + "github.com/volatiletech/sqlboiler/v4/queries/qmhelper" + "github.com/volatiletech/sqlboiler/v4/types" + "github.com/volatiletech/strmangle" +) + +// TestResource is an object representing the database table. +type TestResource struct { + ID string `boil:"id" json:"id" toml:"id" yaml:"id"` + NumericField types.Decimal `boil:"numeric_field" json:"numeric_field" toml:"numeric_field" yaml:"numeric_field"` + NumericNullField types.NullDecimal `boil:"numeric_null_field" json:"numeric_null_field,omitempty" toml:"numeric_null_field" yaml:"numeric_null_field,omitempty"` + IntegerField int `boil:"integer_field" json:"integer_field" toml:"integer_field" yaml:"integer_field"` + IntegerNullField null.Int `boil:"integer_null_field" json:"integer_null_field,omitempty" toml:"integer_null_field" yaml:"integer_null_field,omitempty"` + BoolField bool `boil:"bool_field" json:"bool_field" toml:"bool_field" yaml:"bool_field"` + BoolNullField null.Bool `boil:"bool_null_field" json:"bool_null_field,omitempty" toml:"bool_null_field" yaml:"bool_null_field,omitempty"` + DecimalField types.Decimal `boil:"decimal_field" json:"decimal_field" toml:"decimal_field" yaml:"decimal_field"` + DecimalNullField types.NullDecimal `boil:"decimal_null_field" json:"decimal_null_field,omitempty" toml:"decimal_null_field" yaml:"decimal_null_field,omitempty"` + TextField string `boil:"text_field" json:"text_field" toml:"text_field" yaml:"text_field"` + TextNullField null.String `boil:"text_null_field" json:"text_null_field,omitempty" toml:"text_null_field" yaml:"text_null_field,omitempty"` + TimtestamptzNullField null.Time `boil:"timtestamptz_null_field" json:"timtestamptz_null_field,omitempty" toml:"timtestamptz_null_field" yaml:"timtestamptz_null_field,omitempty"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *testResourceR `boil:"-" json:"-" toml:"-" yaml:"-"` + L testResourceL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var TestResourceColumns = struct { + ID string + NumericField string + NumericNullField string + IntegerField string + IntegerNullField string + BoolField string + BoolNullField string + DecimalField string + DecimalNullField string + TextField string + TextNullField string + TimtestamptzNullField string + CreatedAt string + UpdatedAt string +}{ + ID: "id", + NumericField: "numeric_field", + NumericNullField: "numeric_null_field", + IntegerField: "integer_field", + IntegerNullField: "integer_null_field", + BoolField: "bool_field", + BoolNullField: "bool_null_field", + DecimalField: "decimal_field", + DecimalNullField: "decimal_null_field", + TextField: "text_field", + TextNullField: "text_null_field", + TimtestamptzNullField: "timtestamptz_null_field", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// Generated where + +type whereHelpernull_Bool struct{ field string } + +func (w whereHelpernull_Bool) EQ(x null.Bool) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, false, x) +} +func (w whereHelpernull_Bool) NEQ(x null.Bool) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, true, x) +} +func (w whereHelpernull_Bool) IsNull() qm.QueryMod { return qmhelper.WhereIsNull(w.field) } +func (w whereHelpernull_Bool) IsNotNull() qm.QueryMod { return qmhelper.WhereIsNotNull(w.field) } +func (w whereHelpernull_Bool) LT(x null.Bool) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpernull_Bool) LTE(x null.Bool) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpernull_Bool) GT(x null.Bool) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpernull_Bool) GTE(x null.Bool) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +var TestResourceWhere = struct { + ID whereHelperstring + NumericField whereHelpertypes_Decimal + NumericNullField whereHelpertypes_NullDecimal + IntegerField whereHelperint + IntegerNullField whereHelpernull_Int + BoolField whereHelperbool + BoolNullField whereHelpernull_Bool + DecimalField whereHelpertypes_Decimal + DecimalNullField whereHelpertypes_NullDecimal + TextField whereHelperstring + TextNullField whereHelpernull_String + TimtestamptzNullField whereHelpernull_Time + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + ID: whereHelperstring{field: "\"test_resource\".\"id\""}, + NumericField: whereHelpertypes_Decimal{field: "\"test_resource\".\"numeric_field\""}, + NumericNullField: whereHelpertypes_NullDecimal{field: "\"test_resource\".\"numeric_null_field\""}, + IntegerField: whereHelperint{field: "\"test_resource\".\"integer_field\""}, + IntegerNullField: whereHelpernull_Int{field: "\"test_resource\".\"integer_null_field\""}, + BoolField: whereHelperbool{field: "\"test_resource\".\"bool_field\""}, + BoolNullField: whereHelpernull_Bool{field: "\"test_resource\".\"bool_null_field\""}, + DecimalField: whereHelpertypes_Decimal{field: "\"test_resource\".\"decimal_field\""}, + DecimalNullField: whereHelpertypes_NullDecimal{field: "\"test_resource\".\"decimal_null_field\""}, + TextField: whereHelperstring{field: "\"test_resource\".\"text_field\""}, + TextNullField: whereHelpernull_String{field: "\"test_resource\".\"text_null_field\""}, + TimtestamptzNullField: whereHelpernull_Time{field: "\"test_resource\".\"timtestamptz_null_field\""}, + CreatedAt: whereHelpertime_Time{field: "\"test_resource\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"test_resource\".\"updated_at\""}, +} + +// TestResourceRels is where relationship names are stored. +var TestResourceRels = struct { +}{} + +// testResourceR is where relationships are stored. +type testResourceR struct { +} + +// NewStruct creates a new relationship struct +func (*testResourceR) NewStruct() *testResourceR { + return &testResourceR{} +} + +// testResourceL is where Load methods for each relationship are stored. +type testResourceL struct{} + +var ( + testResourceAllColumns = []string{"id", "numeric_field", "numeric_null_field", "integer_field", "integer_null_field", "bool_field", "bool_null_field", "decimal_field", "decimal_null_field", "text_field", "text_null_field", "timtestamptz_null_field", "created_at", "updated_at"} + testResourceColumnsWithoutDefault = []string{"id", "numeric_field", "numeric_null_field", "integer_field", "integer_null_field", "bool_field", "bool_null_field", "decimal_field", "decimal_null_field", "text_field", "text_null_field", "timtestamptz_null_field", "created_at", "updated_at"} + testResourceColumnsWithDefault = []string{} + testResourcePrimaryKeyColumns = []string{"id"} +) + +type ( + // TestResourceSlice is an alias for a slice of pointers to TestResource. + // This should generally be used opposed to []TestResource. + TestResourceSlice []*TestResource + + testResourceQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + testResourceType = reflect.TypeOf(&TestResource{}) + testResourceMapping = queries.MakeStructMapping(testResourceType) + testResourcePrimaryKeyMapping, _ = queries.BindMapping(testResourceType, testResourceMapping, testResourcePrimaryKeyColumns) + testResourceInsertCacheMut sync.RWMutex + testResourceInsertCache = make(map[string]insertCache) + testResourceUpdateCacheMut sync.RWMutex + testResourceUpdateCache = make(map[string]updateCache) + testResourceUpsertCacheMut sync.RWMutex + testResourceUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// One returns a single testResource record from the query. +func (q testResourceQuery) One(ctx context.Context, exec boil.ContextExecutor) (*TestResource, error) { + o := &TestResource{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for test_resource") + } + + return o, nil +} + +// All returns all TestResource records from the query. +func (q testResourceQuery) All(ctx context.Context, exec boil.ContextExecutor) (TestResourceSlice, error) { + var o []*TestResource + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to TestResource slice") + } + + return o, nil +} + +// Count returns the count of all TestResource records in the query. +func (q testResourceQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count test_resource rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q testResourceQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if test_resource exists") + } + + return count > 0, nil +} + +// TestResources retrieves all the records using an executor. +func TestResources(mods ...qm.QueryMod) testResourceQuery { + mods = append(mods, qm.From("\"test_resource\"")) + return testResourceQuery{NewQuery(mods...)} +} + +// FindTestResource retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindTestResource(ctx context.Context, exec boil.ContextExecutor, iD string, selectCols ...string) (*TestResource, error) { + testResourceObj := &TestResource{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"test_resource\" where \"id\"=$1", sel, + ) + + q := queries.Raw(query, iD) + + err := q.Bind(ctx, exec, testResourceObj) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from test_resource") + } + + return testResourceObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *TestResource) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no test_resource provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(testResourceColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + testResourceInsertCacheMut.RLock() + cache, cached := testResourceInsertCache[key] + testResourceInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + testResourceAllColumns, + testResourceColumnsWithDefault, + testResourceColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(testResourceType, testResourceMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(testResourceType, testResourceMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"test_resource\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"test_resource\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into test_resource") + } + + if !cached { + testResourceInsertCacheMut.Lock() + testResourceInsertCache[key] = cache + testResourceInsertCacheMut.Unlock() + } + + return nil +} + +// Update uses an executor to update the TestResource. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *TestResource) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + testResourceUpdateCacheMut.RLock() + cache, cached := testResourceUpdateCache[key] + testResourceUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + testResourceAllColumns, + testResourcePrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update test_resource, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"test_resource\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, testResourcePrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(testResourceType, testResourceMapping, append(wl, testResourcePrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update test_resource row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for test_resource") + } + + if !cached { + testResourceUpdateCacheMut.Lock() + testResourceUpdateCache[key] = cache + testResourceUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values. +func (q testResourceQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for test_resource") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for test_resource") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o TestResourceSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), testResourcePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"test_resource\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, testResourcePrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in testResource slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all testResource") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *TestResource) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns) error { + if o == nil { + return errors.New("models: no test_resource provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(testResourceColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + testResourceUpsertCacheMut.RLock() + cache, cached := testResourceUpsertCache[key] + testResourceUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, ret := insertColumns.InsertColumnSet( + testResourceAllColumns, + testResourceColumnsWithDefault, + testResourceColumnsWithoutDefault, + nzDefaults, + ) + update := updateColumns.UpdateColumnSet( + testResourceAllColumns, + testResourcePrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert test_resource, could not build update column list") + } + + conflict := conflictColumns + if len(conflict) == 0 { + conflict = make([]string, len(testResourcePrimaryKeyColumns)) + copy(conflict, testResourcePrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"test_resource\"", updateOnConflict, ret, update, conflict, insert) + + cache.valueMapping, err = queries.BindMapping(testResourceType, testResourceMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(testResourceType, testResourceMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if err == sql.ErrNoRows { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert test_resource") + } + + if !cached { + testResourceUpsertCacheMut.Lock() + testResourceUpsertCache[key] = cache + testResourceUpsertCacheMut.Unlock() + } + + return nil +} + +// Delete deletes a single TestResource record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *TestResource) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no TestResource provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), testResourcePrimaryKeyMapping) + sql := "DELETE FROM \"test_resource\" WHERE \"id\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from test_resource") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for test_resource") + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q testResourceQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no testResourceQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from test_resource") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for test_resource") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o TestResourceSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), testResourcePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"test_resource\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, testResourcePrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from testResource slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for test_resource") + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *TestResource) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindTestResource(ctx, exec, o.ID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *TestResourceSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := TestResourceSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), testResourcePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"test_resource\".* FROM \"test_resource\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, testResourcePrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in TestResourceSlice") + } + + *o = slice + + return nil +} + +// TestResourceExists checks if the TestResource row exists. +func TestResourceExists(ctx context.Context, exec boil.ContextExecutor, iD string) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"test_resource\" where \"id\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, iD) + } + row := exec.QueryRowContext(ctx, sql, iD) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if test_resource exists") + } + + return exists, nil +} diff --git a/scripts/internal/util/get_module_name.go b/scripts/internal/util/get_module_name.go new file mode 100644 index 0000000..e4ab6bd --- /dev/null +++ b/scripts/internal/util/get_module_name.go @@ -0,0 +1,28 @@ +//go:build scripts + +// nolint:revive +package util + +import ( + "fmt" + "log" + "os" + + "golang.org/x/mod/modfile" +) + +// GetModuleName returns the current go module's name as defined in the go.mod file. +// https://stackoverflow.com/questions/53183356/api-to-get-the-module-name +// https://github.com/rogpeppe/go-internal +func GetModuleName(absolutePathToGoMod string) (string, error) { + dat, err := os.ReadFile(absolutePathToGoMod) + + if err != nil { + log.Fatal(err) + return "", fmt.Errorf("failed to read go.mod file: %w", err) + } + + mod := modfile.ModulePath(dat) + + return mod, nil +} diff --git a/scripts/internal/util/get_project_root_dir.go b/scripts/internal/util/get_project_root_dir.go new file mode 100644 index 0000000..42ef634 --- /dev/null +++ b/scripts/internal/util/get_project_root_dir.go @@ -0,0 +1,14 @@ +//go:build scripts + +// nolint:revive +package util + +import "os" + +func GetProjectRootDir() string { + if val, ok := os.LookupEnv("PROJECT_ROOT_DIR"); ok { + return val + } + + return "/app" +} diff --git a/scripts/main.go b/scripts/main.go new file mode 100644 index 0000000..7243bfd --- /dev/null +++ b/scripts/main.go @@ -0,0 +1,9 @@ +//go:build scripts + +package main + +import "allaboutapps.dev/aw/go-starter/scripts/cmd" + +func main() { + cmd.Execute() +} diff --git a/scripts/sql/default_zero_values.sql b/scripts/sql/default_zero_values.sql new file mode 100644 index 0000000..199fb71 --- /dev/null +++ b/scripts/sql/default_zero_values.sql @@ -0,0 +1,89 @@ +-- Errors if DEFAULT values for certain column data_types +-- is NOT set to the golang zero value +-- +-- https://github.com/volatiletech/sqlboiler/issues/409 +-- https://github.com/volatiletech/sqlboiler/issues/237 +-- https://golang.org/ref/spec#The_zero_value +-- +-- https://github.com/volatiletech/sqlboiler#diagnosing-problems +-- A field not being inserted (usually a default true boolean), boil.Infer +-- looks at the zero value of your Go type (it doesn’t care what the default +-- value in the database is) to determine if it should insert your field or +-- not. In the case of a default true boolean value, when you want to set it to +-- false; you set that in the struct but that’s the zero value for the bool +-- field in Go so sqlboiler assumes you do not want to insert that field and +-- you want the default value from the database. Use a whitelist/greylist to +-- add that field to the list of fields to insert. +-- +-- boil.Infer() assumes all SQL defaults are Go zero value +-- +-- To mitigate the above situation we disallow setting DEFAULT to anything +-- other than the default golang zero value of this type. Otherwise this issue +-- is fairly hard to debug (boil.Infer() does not insert DEFAULT as expected). +-- +-- If a default value is actually set, we only allow the respective mapped golang zero value: +-- 0 for all integer types +-- 0.0 for floating point numbers +-- false for booleans +-- "" for strings +-- nil for pointers +-- +-- https://stackoverflow.com/questions/8146448/get-the-default-values-of-table-columns-in-postgres +-- https://dba.stackexchange.com/questions/205471/why-does-information-schema-have-yes-and-no-character-strings-rather-than-bo +CREATE OR REPLACE FUNCTION check_default_go_sql_zero_values () + RETURNS SETOF information_schema.columns + AS $BODY$ +BEGIN + RETURN QUERY + SELECT + * + FROM + information_schema.columns + WHERE (table_schema = 'public' + AND column_default IS NOT NULL) + AND is_nullable = 'NO' + AND ((data_type = 'boolean' + AND column_default <> 'false') + OR (data_type IN ('char', 'character', 'varchar', 'character varying', 'text') + AND column_default NOT LIKE concat('''''', '::%')) + OR (data_type IN ('smallint', 'integer', 'bigint', 'smallserial', 'serial', 'bigserial') + AND (column_default <> '0' + AND column_default NOT LIKE 'nextval(%')) + OR (data_type IN ('decimal', 'numeric', 'real', 'double precision') + AND column_default <> '0.0')); +END +$BODY$ +LANGUAGE plpgsql +SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION default_zero_values () + RETURNS void + AS $$ +DECLARE + item record; +BEGIN + FOR item IN + SELECT + table_name, + column_name, + data_type, + column_default, + is_nullable + FROM + check_default_go_sql_zero_values () + LOOP + RAISE WARNING ' %.% % : INVALID DEFAULT ''%''', item.table_name, item.column_name, item.data_type, item.column_default USING HINT = to_json(item); +END LOOP; + IF FOUND THEN + RAISE EXCEPTION 'NOT NULL columns require the respective go zero value () AS their DEFAULT value or no DEFAULT at all' + USING HINT = '0 for integer types, 0.0 for floating point numbers, false for booleans, "" for strings'; + END IF; +END; +$$ +LANGUAGE plpgsql; + +SELECT + * +FROM + default_zero_values (); + diff --git a/scripts/sql/fk_missing_index.sql b/scripts/sql/fk_missing_index.sql new file mode 100644 index 0000000..4c55110 --- /dev/null +++ b/scripts/sql/fk_missing_index.sql @@ -0,0 +1,57 @@ +-- Errors if FKs do not have an index +CREATE OR REPLACE FUNCTION fk_missing_index () + RETURNS void + AS $$ +DECLARE + item record; +BEGIN + FOR item IN + SELECT + c.conrelid::regclass AS "table", + /* list of key column names in order */ + string_agg(a.attname, ',' ORDER BY x.n) AS columns, + pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(c.conrelid)) AS size, + c.conname AS constraint, + c.confrelid::regclass AS referenced_table + FROM + pg_catalog.pg_constraint c + /* enumerated key column numbers per foreign key */ + CROSS JOIN LATERAL unnest(c.conkey) + WITH ORDINALITY AS x (attnum, n) + /* name for each key column */ + JOIN pg_catalog.pg_attribute a ON a.attnum = x.attnum + AND a.attrelid = c.conrelid +WHERE + NOT EXISTS + /* is there a matching index for the constraint? */ + ( + SELECT + 1 + FROM + pg_catalog.pg_index i + WHERE + i.indrelid = c.conrelid + /* the first index columns must be the same as the + key columns, but order doesn't matter */ + AND (i.indkey::smallint[])[0:cardinality(c.conkey) - 1] @> c.conkey) + AND c.contype = 'f' + GROUP BY + c.conrelid, + c.conname, + c.confrelid + ORDER BY + pg_catalog.pg_relation_size(c.conrelid) DESC LOOP + RAISE WARNING 'CREATE INDEX "idx_%_fk_%" ON "%" ("%");', item.table, item.columns, item.table, item.columns USING HINT = to_json(item); + END LOOP; + IF FOUND THEN + RAISE EXCEPTION ' We require ALL FOREIGN keys TO have an INDEX defined. '; + END IF; +END; +$$ +LANGUAGE plpgsql; + +SELECT + * +FROM + fk_missing_index (); + diff --git a/scripts/sql/info.sql b/scripts/sql/info.sql new file mode 100644 index 0000000..84c6dd2 --- /dev/null +++ b/scripts/sql/info.sql @@ -0,0 +1,15 @@ +-- This file just queries for the currently applied tables/columns as an overview +SELECT + table_name, + column_name, + data_type, + column_default, + is_nullable +FROM + information_schema.columns +WHERE (table_schema = 'public') +ORDER BY + table_name, + is_nullable, + column_name; + diff --git a/sqlboiler.toml b/sqlboiler.toml new file mode 100644 index 0000000..88ec3e1 --- /dev/null +++ b/sqlboiler.toml @@ -0,0 +1,10 @@ +wipe = true +no-hooks = true +output = "internal/models" + +[psql] + # Connection params are injected via env vars (PSQL_*) + # As those are supplied via the env, you cannot overwrite the here! + # see https://github.com/volatiletech/sqlboiler/issues/678 + # see https://github.com/volatiletech/sqlboiler/pull/615 + blacklist = ["migrations"] \ No newline at end of file diff --git a/test/testdata/android-assetlinks.json b/test/testdata/android-assetlinks.json new file mode 100644 index 0000000..d8b928a --- /dev/null +++ b/test/testdata/android-assetlinks.json @@ -0,0 +1,14 @@ +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "test.at.aaa.b2b", + "sha256_cert_fingerprints": [ + "BB:A6:3E:A7:91:F4:05:2C:6D:47:8B:1A:3F:C5:9E:0D:7B:2F:1A:E8:D5:6C:10:52:8B:F4:B7:A9:1D:C3:57:43" + ] + } + } +] \ No newline at end of file diff --git a/test/testdata/apple-app-site-association.json b/test/testdata/apple-app-site-association.json new file mode 100644 index 0000000..feebd51 --- /dev/null +++ b/test/testdata/apple-app-site-association.json @@ -0,0 +1,18 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "test.at.aaa.b2b", + "paths": [ + "/api/v*/auth/register" + ] + } + ] + }, + "webcredentials": { + "apps": [ + "test.at.aaa.b2b" + ] + } +} \ No newline at end of file diff --git a/test/testdata/example.jpg b/test/testdata/example.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59ff9c0ba3d8d5f1eb7fa6bbdbc2d57e8989a110 GIT binary patch literal 6749 zcmeH}Sx^&58pk^qNg#-UFoGZn1m##j36UeqKp=+8m4FZ!K;@QGxp81bE@d=}Trnt@ zi-3Yi02SmI1urnbfPz90bQ~an!k|b*UH_;4s_XlG{d;Kf zu=r=N)xpNz20$PX;IpxTSOhF+2kBO>jDr+HP=J1DWMB{>-q2tVK|3xcQ3F00gl?y9xY{1%blg2q`2AEiJQA(6|*q zAut#e4nrW|@Qv!EjdK9UBIMN#t)vv3eUTbbI8tiv6%^6><^#op{qveee$i=Y>94ja zDR1AoOG{fvm%P{5#MF#J+ef#twX=6{ab>!>f8*ilzcH+!;E>Rm*tqx;-zFrcpFWfE z{n>M@JoW`n{>4iLg=OUxSFcr8U9Y}%yP@$;Q*%q}!$*%hI-l^mx(A-W7#!jczZ@AG zpAbw=O}~9N^Kn7=Y4P*Ym*tgBE(n1A9qU)Jf8oMza6#d47#z9D1%aN}2n-8Hs2fVj zTR9_rqZBkqsVJOv?v3|&`(0$eg8h?g1Yls0 zjmLvwfhG9VtzDKPEs10X5(gv>NF0zjAaOwAz;AY-n$P#^ds#6veOm2Wil*14BV(oR zKUS06cUbm?dOjQ|&(rs0UJwbpU%hc@5CczsYXNs|WM3xl5nlU4FQ9mgpUDjfS5^tl zjN7|2Chzc}Lo;q%^3lEW27`pG@m31R3K?%Yu+U6jB=dG3t92Z{>Sj?QH|vpxt)5{k zqZibdsmQwJsq-|(e$$~Del$5i-l!0al$B>Vld;_tN;=~x>S{KX6lp}2q0=JbWCw0r zucj5Np+vFlBUTX_6?GZOtw76?0~mD0{jM!xVHz$UT85jKN|8NPBeY#lN@!b3p;61s zvjTf3^KfN`wd~Q+w|8by!UqW`!FwX!!Ar)YrY`WTl<|+GnxtTuwTJ&18Qz%diM{Yd zPH|nM)}hrj%Mz!SKhLc^eJSwMIA@fS_(=@#10_inUOUUXwrcZC;&_%NAnb+0Potx% z2ee)II~ebn}y?KX|}Whx_wSpYEQot!?_FojuI7R5mz>HD)SJ%r8o_<*w6|VnOd`gYePiybW{!t7( z8ji0_6oS4%%ZXJ-F{o`H3_Eyhk?}?JynVKA(ep(qhAUGPQoKk?wsMc?3^zco!bP0@ zHWnT&DeD5Z7;x8hhbjr#j+ZXaHk>kdf`-_}7oa~A7xzY2zpHX_Ou@gO7EW=Uq*Rh0 zS2EuW{B*o|P7G!$-@T2ft7ywP$%<04oNnCyLs7x_t#dSs6Zp^~db3>kv0=ow*$iju z_Bb}zK_jpKQXnm#$XDWq_6$B#)_dLd^F_?5W-*Wk>Ab;O+mh*o1i`L__EJ%!|ABjj z_D6=^`UOl|G*)`-&i-OIF)9Y;q6nTCELK5xFgw~x*9&)TkmYMC=i2L>F1Zp`AB=9Yt5Z)Bui#65aSY%X| zrR>R{aBEVt&GA;18fMJsP89{}=`C&3GV0AjsL!}{&ULAXu zEj*hr@tld7u#G;_h6eSyZgO|Qa`yEP9u`bd0g-!4+1EeE_`T@xW}{uV8e1+4=Psrr zYcYevT$ZPF!ZR3#3dBtRY{$rCivFB{<%7S#)Ra%W*_oUx25nW)fgTkz zdYm4m%A93IrnAm%t%v6*1^9Da@7&2V(~z6O@3u5{37HrU5>~1lHID8C?jg!Q#0Uka zE-}>_~hM~fvc(B zb4Cu&mVG(YE@jQ2=QG-n-pK3G{k*p^o@U6QRM3;V!TnNkaa`XwDrVSqR<8_ z2yd4CY$wZrB;mP2e zomJ(N&Z(|#kHkR!&F-bP2=@85F#mW;+!ODIDOzFq9w*yxJJ`cB6$O|W{wtS(F|~0! zs(N{6%(IV|Ee&ZzmF8D+7l_cLu0zR0r+YVe3-$gg+q&zgb)8^q?G7OEh^=P$j7lhv z`0hpCo>RrFo30~ByGgW~ZDD3LYSQNE{9e<18~E~;N`fzI0WFwkH~?CtN&Pp1$VyDN z6l86AO((F}<^j|^%O?IBB~j>HR|JXOT7bJ5=yN~LY}(R0F*eO^npAO=+KsCsVAG=b zvs4s6S2I{(to#|9<+>_zU25LfoF$JA>N9*6$*|0=vgmuytgsSxALf58^{{=+A-_)L vaYtq*Uhhe!wlz|?9t?erbEYExmof26DEeHx<{xj?fpdaY{hQCb_~l;#M&`!= literal 0 HcmV?d00001 diff --git a/test/testdata/plain.sql b/test/testdata/plain.sql new file mode 100644 index 0000000..c9d689f --- /dev/null +++ b/test/testdata/plain.sql @@ -0,0 +1,45 @@ +-- +-- PostgreSQL database dump +-- +-- Dumped from database version 12.4 +-- Dumped by pg_dump version 12.4 +SET statement_timeout = 0; + +SET lock_timeout = 0; + +SET idle_in_transaction_session_timeout = 0; + +SET client_encoding = 'UTF8'; + +SET standard_conforming_strings = ON; + +SELECT + pg_catalog.set_config('search_path', '', FALSE); + +SET check_function_bodies = FALSE; + +SET xmloption = content; + +SET client_min_messages = warning; + +SET row_security = OFF; + +DROP EXTENSION IF EXISTS "uuid-ossp"; + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- PostgreSQL database dump complete +-- diff --git a/test/testdata/snapshots/TestGetAndroidWellKnown.golden b/test/testdata/snapshots/TestGetAndroidWellKnown.golden new file mode 100644 index 0000000..d8b928a --- /dev/null +++ b/test/testdata/snapshots/TestGetAndroidWellKnown.golden @@ -0,0 +1,14 @@ +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "test.at.aaa.b2b", + "sha256_cert_fingerprints": [ + "BB:A6:3E:A7:91:F4:05:2C:6D:47:8B:1A:3F:C5:9E:0D:7B:2F:1A:E8:D5:6C:10:52:8B:F4:B7:A9:1D:C3:57:43" + ] + } + } +] \ No newline at end of file diff --git a/test/testdata/snapshots/TestGetAppleWellKnown.golden b/test/testdata/snapshots/TestGetAppleWellKnown.golden new file mode 100644 index 0000000..feebd51 --- /dev/null +++ b/test/testdata/snapshots/TestGetAppleWellKnown.golden @@ -0,0 +1,18 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "test.at.aaa.b2b", + "paths": [ + "/api/v*/auth/register" + ] + } + ] + }, + "webcredentials": { + "apps": [ + "test.at.aaa.b2b" + ] + } +} \ No newline at end of file diff --git a/test/testdata/snapshots/TestGetCompleteRegister.golden b/test/testdata/snapshots/TestGetCompleteRegister.golden new file mode 100644 index 0000000..8d70273 --- /dev/null +++ b/test/testdata/snapshots/TestGetCompleteRegister.golden @@ -0,0 +1,30 @@ + + + + + Account confirmation + + +
Processing request...
+ + + + \ No newline at end of file diff --git a/test/testdata/snapshots/TestGetUserInfo.golden b/test/testdata/snapshots/TestGetUserInfo.golden new file mode 100644 index 0000000..fee38dc --- /dev/null +++ b/test/testdata/snapshots/TestGetUserInfo.golden @@ -0,0 +1,8 @@ +(types.GetUserInfoResponse) { + Email: (strfmt.Email) (len=17) user1@example.com, + Scopes: ([]string) (len=1) { + (string) (len=3) "app" + }, + Sub: (*string)((len=36) "f6ede5d8-e22a-4ca5-aa12-67821865a3e5"), + UpdatedAt: , +} diff --git a/test/testdata/snapshots/TestILikeArgs.golden b/test/testdata/snapshots/TestILikeArgs.golden new file mode 100644 index 0000000..70e71ba --- /dev/null +++ b/test/testdata/snapshots/TestILikeArgs.golden @@ -0,0 +1,2 @@ +(string) (len=12) "%Max.Muster%" +(string) (len=3) "Max" diff --git a/test/testdata/snapshots/TestILikeSQL.golden b/test/testdata/snapshots/TestILikeSQL.golden new file mode 100644 index 0000000..9a63f13 --- /dev/null +++ b/test/testdata/snapshots/TestILikeSQL.golden @@ -0,0 +1 @@ +(string) (len=171) "SELECT * FROM \"users\" INNER JOIN app_user_profiles ON app_user_profiles.user_id=users.id WHERE (users.username ILIKE $1) AND (users.app_user_profiles.first_name ILIKE $2);" diff --git a/test/testdata/snapshots/TestILikeSearchArgs.golden b/test/testdata/snapshots/TestILikeSearchArgs.golden new file mode 100644 index 0000000..4b0baf8 --- /dev/null +++ b/test/testdata/snapshots/TestILikeSearchArgs.golden @@ -0,0 +1,2 @@ +(string) (len=10) "%mus\\%ter%" +(string) (len=7) "%m\\_ax%" diff --git a/test/testdata/snapshots/TestILikeSearchSQL.golden b/test/testdata/snapshots/TestILikeSearchSQL.golden new file mode 100644 index 0000000..e7c70e4 --- /dev/null +++ b/test/testdata/snapshots/TestILikeSearchSQL.golden @@ -0,0 +1 @@ +(string) (len=149) "SELECT * FROM \"users\" INNER JOIN app_user_profiles ON app_user_profiles.user_id=users.id WHERE (users.username ILIKE $1 AND users.username ILIKE $2);" diff --git a/test/testdata/snapshots/TestLeftOuterJoinArgs.golden b/test/testdata/snapshots/TestLeftOuterJoinArgs.golden new file mode 100644 index 0000000..e69de29 diff --git a/test/testdata/snapshots/TestLeftOuterJoinSQL.golden b/test/testdata/snapshots/TestLeftOuterJoinSQL.golden new file mode 100644 index 0000000..4a9ab58 --- /dev/null +++ b/test/testdata/snapshots/TestLeftOuterJoinSQL.golden @@ -0,0 +1 @@ +(string) (len=88) "SELECT * FROM \"users\" LEFT JOIN app_user_profiles ON app_user_profiles.user_id=users.id;" diff --git a/test/testdata/snapshots/TestLeftOuterJoinWithFilterArgs.golden b/test/testdata/snapshots/TestLeftOuterJoinWithFilterArgs.golden new file mode 100644 index 0000000..6f2e62c --- /dev/null +++ b/test/testdata/snapshots/TestLeftOuterJoinWithFilterArgs.golden @@ -0,0 +1 @@ +(string) (len=3) "Max" diff --git a/test/testdata/snapshots/TestLeftOuterJoinWithFilterSQL.golden b/test/testdata/snapshots/TestLeftOuterJoinWithFilterSQL.golden new file mode 100644 index 0000000..2621c3e --- /dev/null +++ b/test/testdata/snapshots/TestLeftOuterJoinWithFilterSQL.golden @@ -0,0 +1 @@ +(string) (len=124) "SELECT * FROM \"users\" LEFT JOIN app_user_profiles ON app_user_profiles.user_id=users.id AND app_user_profiles.first_name=$1;" diff --git a/test/testdata/snapshots/TestLogErrorFuncWithRequestInfo.golden b/test/testdata/snapshots/TestLogErrorFuncWithRequestInfo.golden new file mode 100644 index 0000000..4aabea3 --- /dev/null +++ b/test/testdata/snapshots/TestLogErrorFuncWithRequestInfo.golden @@ -0,0 +1 @@ +{"status":500,"title":"Internal Server Error","type":"generic"} diff --git a/test/testdata/snapshots/TestNINArgs.golden b/test/testdata/snapshots/TestNINArgs.golden new file mode 100644 index 0000000..c00f1bc --- /dev/null +++ b/test/testdata/snapshots/TestNINArgs.golden @@ -0,0 +1,5 @@ +(pq.StringArray) (len=3) { + (string) (len=3) "max", + (string) (len=6) "muster", + (string) (len=5) "peter" +} diff --git a/test/testdata/snapshots/TestNINSQL.golden b/test/testdata/snapshots/TestNINSQL.golden new file mode 100644 index 0000000..006963d --- /dev/null +++ b/test/testdata/snapshots/TestNINSQL.golden @@ -0,0 +1 @@ +(string) (len=135) "SELECT * FROM \"users\" INNER JOIN app_user_profiles ON app_user_profiles.user_id=users.id WHERE (app_user_profiles.username <> all($1));" diff --git a/test/testdata/snapshots/TestNotFound-AcceptApplicationJSON.golden b/test/testdata/snapshots/TestNotFound-AcceptApplicationJSON.golden new file mode 100644 index 0000000..5f62270 --- /dev/null +++ b/test/testdata/snapshots/TestNotFound-AcceptApplicationJSON.golden @@ -0,0 +1 @@ +(string) (len=52) "{\"status\":404,\"title\":\"Not Found\",\"type\":\"generic\"}\n" diff --git a/test/testdata/snapshots/TestNotFound-AcceptTextHTML.golden b/test/testdata/snapshots/TestNotFound-AcceptTextHTML.golden new file mode 100644 index 0000000..940907b --- /dev/null +++ b/test/testdata/snapshots/TestNotFound-AcceptTextHTML.golden @@ -0,0 +1 @@ +(string) (len=181) "

Page Not Found

The page you are looking for does not exist. Did you mean to visit http://localhost:3000?

" diff --git a/test/testdata/snapshots/TestOrArgs.golden b/test/testdata/snapshots/TestOrArgs.golden new file mode 100644 index 0000000..1644701 --- /dev/null +++ b/test/testdata/snapshots/TestOrArgs.golden @@ -0,0 +1,10 @@ +(int) 123 +(string) (len=22) "max.muster@example.org" +(string) (len=3) "Max" +(string) (len=6) "Muster" +(string) (len=7) "Austria" +(*pq.StringArray)((len=2) { + (string) (len=3) "app", + (string) (len=9) "user_info" +}) +(int) 42 diff --git a/test/testdata/snapshots/TestOrSQL.golden b/test/testdata/snapshots/TestOrSQL.golden new file mode 100644 index 0000000..ff44ea2 --- /dev/null +++ b/test/testdata/snapshots/TestOrSQL.golden @@ -0,0 +1 @@ +(string) (len=247) "SELECT * FROM \"users\" WHERE (id = $1 OR username = $2 OR (users.profile->>'firstName' = $3 AND users.profile->>'lastName' = $4 AND users.profile->>'country' = $5 AND users.profile->'scopes' <@ to_jsonb($6::text[]) AND users.profile->>'age' = $7));" diff --git a/test/testdata/snapshots/TestParseModel_Success.golden b/test/testdata/snapshots/TestParseModel_Success.golden new file mode 100644 index 0000000..04dc4f9 --- /dev/null +++ b/test/testdata/snapshots/TestParseModel_Success.golden @@ -0,0 +1,89 @@ +(*scaffold.StorageResource)({ + Name: (string) (len=12) "TestResource", + Fields: ([]scaffold.Field) (len=14) { + (scaffold.Field) { + Name: (string) (len=2) "ID", + Type: (scaffold.FieldType) { + Name: (string) (len=6) "string" + } + }, + (scaffold.Field) { + Name: (string) (len=12) "NumericField", + Type: (scaffold.FieldType) { + Name: (string) (len=13) "types.Decimal" + } + }, + (scaffold.Field) { + Name: (string) (len=16) "NumericNullField", + Type: (scaffold.FieldType) { + Name: (string) (len=17) "types.NullDecimal" + } + }, + (scaffold.Field) { + Name: (string) (len=12) "IntegerField", + Type: (scaffold.FieldType) { + Name: (string) (len=3) "int" + } + }, + (scaffold.Field) { + Name: (string) (len=16) "IntegerNullField", + Type: (scaffold.FieldType) { + Name: (string) (len=8) "null.Int" + } + }, + (scaffold.Field) { + Name: (string) (len=9) "BoolField", + Type: (scaffold.FieldType) { + Name: (string) (len=4) "bool" + } + }, + (scaffold.Field) { + Name: (string) (len=13) "BoolNullField", + Type: (scaffold.FieldType) { + Name: (string) (len=9) "null.Bool" + } + }, + (scaffold.Field) { + Name: (string) (len=12) "DecimalField", + Type: (scaffold.FieldType) { + Name: (string) (len=13) "types.Decimal" + } + }, + (scaffold.Field) { + Name: (string) (len=16) "DecimalNullField", + Type: (scaffold.FieldType) { + Name: (string) (len=17) "types.NullDecimal" + } + }, + (scaffold.Field) { + Name: (string) (len=9) "TextField", + Type: (scaffold.FieldType) { + Name: (string) (len=6) "string" + } + }, + (scaffold.Field) { + Name: (string) (len=13) "TextNullField", + Type: (scaffold.FieldType) { + Name: (string) (len=11) "null.String" + } + }, + (scaffold.Field) { + Name: (string) (len=21) "TimtestamptzNullField", + Type: (scaffold.FieldType) { + Name: (string) (len=9) "null.Time" + } + }, + (scaffold.Field) { + Name: (string) (len=9) "CreatedAt", + Type: (scaffold.FieldType) { + Name: (string) (len=9) "time.Time" + } + }, + (scaffold.Field) { + Name: (string) (len=9) "UpdatedAt", + Type: (scaffold.FieldType) { + Name: (string) (len=9) "time.Time" + } + } + } +}) diff --git a/test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyCurrentPassword.golden b/test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyCurrentPassword.golden new file mode 100644 index 0000000..d0f5d5b --- /dev/null +++ b/test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyCurrentPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: currentPassword (in body): currentPassword in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyNewPassword.golden b/test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyNewPassword.golden new file mode 100644 index 0000000..3caf93a --- /dev/null +++ b/test/testdata/snapshots/TestPostChangePasswordBadRequest-EmptyNewPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: newPassword (in body): newPassword in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingCurrentPassword.golden b/test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingCurrentPassword.golden new file mode 100644 index 0000000..036a091 --- /dev/null +++ b/test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingCurrentPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: currentPassword (in body): currentPassword in body is required diff --git a/test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingNewPassword.golden b/test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingNewPassword.golden new file mode 100644 index 0000000..f4e8a2d --- /dev/null +++ b/test/testdata/snapshots/TestPostChangePasswordBadRequest-MissingNewPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: newPassword (in body): newPassword in body is required diff --git a/test/testdata/snapshots/TestPostForgotPasswordBadRequest-EmptyUsername.golden b/test/testdata/snapshots/TestPostForgotPasswordBadRequest-EmptyUsername.golden new file mode 100644 index 0000000..4c9860a --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordBadRequest-EmptyUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostForgotPasswordBadRequest-InvalidUsername.golden b/test/testdata/snapshots/TestPostForgotPasswordBadRequest-InvalidUsername.golden new file mode 100644 index 0000000..e026704 --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordBadRequest-InvalidUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body must be of type email: "definitely not an email" diff --git a/test/testdata/snapshots/TestPostForgotPasswordBadRequest-MissingUsername.golden b/test/testdata/snapshots/TestPostForgotPasswordBadRequest-MissingUsername.golden new file mode 100644 index 0000000..8e2ca7a --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordBadRequest-MissingUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body is required diff --git a/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyPassword.golden b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyPassword.golden new file mode 100644 index 0000000..3f0076f --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: password (in body): password in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyToken.golden b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyToken.golden new file mode 100644 index 0000000..51a6e4c --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-EmptyToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: token (in body): token in body must be of type uuid4: "" diff --git a/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-InvalidToken.golden b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-InvalidToken.golden new file mode 100644 index 0000000..32541a7 --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-InvalidToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: token (in body): token in body must be of type uuid4: "definitelydoesnotexist" diff --git a/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingPassword.golden b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingPassword.golden new file mode 100644 index 0000000..ef1cfc0 --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: password (in body): password in body is required diff --git a/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingToken.golden b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingToken.golden new file mode 100644 index 0000000..ca25bb7 --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordCompleteBadRequest-MissingToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: token (in body): token in body is required diff --git a/test/testdata/snapshots/TestPostForgotPasswordCompleteSuccess.golden b/test/testdata/snapshots/TestPostForgotPasswordCompleteSuccess.golden new file mode 100644 index 0000000..2d05985 --- /dev/null +++ b/test/testdata/snapshots/TestPostForgotPasswordCompleteSuccess.golden @@ -0,0 +1,6 @@ +(types.PostLoginResponse) { + AccessToken: , + ExpiresIn: (*int64)(86400), + RefreshToken: , + TokenType: (*string)((len=6) "bearer") +} diff --git a/test/testdata/snapshots/TestPostLoginBadRequest-EmptyPassword.golden b/test/testdata/snapshots/TestPostLoginBadRequest-EmptyPassword.golden new file mode 100644 index 0000000..3f0076f --- /dev/null +++ b/test/testdata/snapshots/TestPostLoginBadRequest-EmptyPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: password (in body): password in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostLoginBadRequest-EmptyUsername.golden b/test/testdata/snapshots/TestPostLoginBadRequest-EmptyUsername.golden new file mode 100644 index 0000000..4c9860a --- /dev/null +++ b/test/testdata/snapshots/TestPostLoginBadRequest-EmptyUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostLoginBadRequest-InvalidUsername.golden b/test/testdata/snapshots/TestPostLoginBadRequest-InvalidUsername.golden new file mode 100644 index 0000000..e026704 --- /dev/null +++ b/test/testdata/snapshots/TestPostLoginBadRequest-InvalidUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body must be of type email: "definitely not an email" diff --git a/test/testdata/snapshots/TestPostLoginBadRequest-MissingPassword.golden b/test/testdata/snapshots/TestPostLoginBadRequest-MissingPassword.golden new file mode 100644 index 0000000..ef1cfc0 --- /dev/null +++ b/test/testdata/snapshots/TestPostLoginBadRequest-MissingPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: password (in body): password in body is required diff --git a/test/testdata/snapshots/TestPostLoginBadRequest-MissingUsername.golden b/test/testdata/snapshots/TestPostLoginBadRequest-MissingUsername.golden new file mode 100644 index 0000000..8e2ca7a --- /dev/null +++ b/test/testdata/snapshots/TestPostLoginBadRequest-MissingUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body is required diff --git a/test/testdata/snapshots/TestPostLogoutInvalidRefreshToken.golden b/test/testdata/snapshots/TestPostLogoutInvalidRefreshToken.golden new file mode 100644 index 0000000..fb27873 --- /dev/null +++ b/test/testdata/snapshots/TestPostLogoutInvalidRefreshToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: refresh_token (in body): refresh_token in body must be of type uuid4: "not my refresh token" diff --git a/test/testdata/snapshots/TestPostRefreshBadRequest-EmptyRefreshToken.golden b/test/testdata/snapshots/TestPostRefreshBadRequest-EmptyRefreshToken.golden new file mode 100644 index 0000000..9f2926d --- /dev/null +++ b/test/testdata/snapshots/TestPostRefreshBadRequest-EmptyRefreshToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: refresh_token (in body): refresh_token in body must be of type uuid4: "" diff --git a/test/testdata/snapshots/TestPostRefreshBadRequest-InvalidToken.golden b/test/testdata/snapshots/TestPostRefreshBadRequest-InvalidToken.golden new file mode 100644 index 0000000..1533df1 --- /dev/null +++ b/test/testdata/snapshots/TestPostRefreshBadRequest-InvalidToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: refresh_token (in body): refresh_token in body must be of type uuid4: "not a valid token" diff --git a/test/testdata/snapshots/TestPostRefreshBadRequest-MissingRefreshToken.golden b/test/testdata/snapshots/TestPostRefreshBadRequest-MissingRefreshToken.golden new file mode 100644 index 0000000..7ab402c --- /dev/null +++ b/test/testdata/snapshots/TestPostRefreshBadRequest-MissingRefreshToken.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: refresh_token (in body): refresh_token in body is required diff --git a/test/testdata/snapshots/TestPostRegisterBadRequest-EmptyPassword.golden b/test/testdata/snapshots/TestPostRegisterBadRequest-EmptyPassword.golden new file mode 100644 index 0000000..3f0076f --- /dev/null +++ b/test/testdata/snapshots/TestPostRegisterBadRequest-EmptyPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: password (in body): password in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostRegisterBadRequest-EmptyUsername.golden b/test/testdata/snapshots/TestPostRegisterBadRequest-EmptyUsername.golden new file mode 100644 index 0000000..4c9860a --- /dev/null +++ b/test/testdata/snapshots/TestPostRegisterBadRequest-EmptyUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body should be at least 1 chars long diff --git a/test/testdata/snapshots/TestPostRegisterBadRequest-InvalidUsername.golden b/test/testdata/snapshots/TestPostRegisterBadRequest-InvalidUsername.golden new file mode 100644 index 0000000..e026704 --- /dev/null +++ b/test/testdata/snapshots/TestPostRegisterBadRequest-InvalidUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body must be of type email: "definitely not an email" diff --git a/test/testdata/snapshots/TestPostRegisterBadRequest-MissingPassword.golden b/test/testdata/snapshots/TestPostRegisterBadRequest-MissingPassword.golden new file mode 100644 index 0000000..ef1cfc0 --- /dev/null +++ b/test/testdata/snapshots/TestPostRegisterBadRequest-MissingPassword.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: password (in body): password in body is required diff --git a/test/testdata/snapshots/TestPostRegisterBadRequest-MissingUsername.golden b/test/testdata/snapshots/TestPostRegisterBadRequest-MissingUsername.golden new file mode 100644 index 0000000..8e2ca7a --- /dev/null +++ b/test/testdata/snapshots/TestPostRegisterBadRequest-MissingUsername.golden @@ -0,0 +1 @@ +(httperrors.HTTPValidationError) HTTPValidationError 400 (generic): Bad Request - Validation: username (in body): username in body is required diff --git a/test/testdata/snapshots/TestSaveResponseAndValidate.golden b/test/testdata/snapshots/TestSaveResponseAndValidate.golden new file mode 100644 index 0000000..bf12462 --- /dev/null +++ b/test/testdata/snapshots/TestSaveResponseAndValidate.golden @@ -0,0 +1,8 @@ +(*types.GetUserInfoResponse)({ + Email: , + Scopes: ([]string) (len=1) { + (string) (len=3) "app" + }, + Sub: (*string)((len=36) "f6ede5d8-e22a-4ca5-aa12-67821865a3e5"), + UpdatedAt: , +}) diff --git a/test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden b/test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden new file mode 100644 index 0000000..76a8e92 --- /dev/null +++ b/test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden @@ -0,0 +1,8 @@ +{ + "email": , + "scopes": [ + "app" + ], + "sub": "f6ede5d8-e22a-4ca5-aa12-67821865a3e5", + "updated_at": , +} diff --git a/test/testdata/snapshots/TestSnapshot.golden b/test/testdata/snapshots/TestSnapshot.golden new file mode 100644 index 0000000..639b6df --- /dev/null +++ b/test/testdata/snapshots/TestSnapshot.golden @@ -0,0 +1,7 @@ +(struct { A string; B int; C bool; D *string }) { + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} +(string) (len=12) "Hello World!" diff --git a/test/testdata/snapshots/TestSnapshotJSONJSON.golden b/test/testdata/snapshots/TestSnapshotJSONJSON.golden new file mode 100644 index 0000000..1fde81a --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotJSONJSON.golden @@ -0,0 +1,19 @@ +{ + "ID": , + "A": "foo", + "B": 1, + "C": true, + "D": { + "Foo": "skip me", + "Bar": 3 + }, + "E": [ + "skip me", + "skip me too" + ], + "F": { + "skip me": 1, + "skip me three": 3, + "skip me too": 2 + } +} \ No newline at end of file diff --git a/test/testdata/snapshots/TestSnapshotSaveBytesImage.jpg b/test/testdata/snapshots/TestSnapshotSaveBytesImage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59ff9c0ba3d8d5f1eb7fa6bbdbc2d57e8989a110 GIT binary patch literal 6749 zcmeH}Sx^&58pk^qNg#-UFoGZn1m##j36UeqKp=+8m4FZ!K;@QGxp81bE@d=}Trnt@ zi-3Yi02SmI1urnbfPz90bQ~an!k|b*UH_;4s_XlG{d;Kf zu=r=N)xpNz20$PX;IpxTSOhF+2kBO>jDr+HP=J1DWMB{>-q2tVK|3xcQ3F00gl?y9xY{1%blg2q`2AEiJQA(6|*q zAut#e4nrW|@Qv!EjdK9UBIMN#t)vv3eUTbbI8tiv6%^6><^#op{qveee$i=Y>94ja zDR1AoOG{fvm%P{5#MF#J+ef#twX=6{ab>!>f8*ilzcH+!;E>Rm*tqx;-zFrcpFWfE z{n>M@JoW`n{>4iLg=OUxSFcr8U9Y}%yP@$;Q*%q}!$*%hI-l^mx(A-W7#!jczZ@AG zpAbw=O}~9N^Kn7=Y4P*Ym*tgBE(n1A9qU)Jf8oMza6#d47#z9D1%aN}2n-8Hs2fVj zTR9_rqZBkqsVJOv?v3|&`(0$eg8h?g1Yls0 zjmLvwfhG9VtzDKPEs10X5(gv>NF0zjAaOwAz;AY-n$P#^ds#6veOm2Wil*14BV(oR zKUS06cUbm?dOjQ|&(rs0UJwbpU%hc@5CczsYXNs|WM3xl5nlU4FQ9mgpUDjfS5^tl zjN7|2Chzc}Lo;q%^3lEW27`pG@m31R3K?%Yu+U6jB=dG3t92Z{>Sj?QH|vpxt)5{k zqZibdsmQwJsq-|(e$$~Del$5i-l!0al$B>Vld;_tN;=~x>S{KX6lp}2q0=JbWCw0r zucj5Np+vFlBUTX_6?GZOtw76?0~mD0{jM!xVHz$UT85jKN|8NPBeY#lN@!b3p;61s zvjTf3^KfN`wd~Q+w|8by!UqW`!FwX!!Ar)YrY`WTl<|+GnxtTuwTJ&18Qz%diM{Yd zPH|nM)}hrj%Mz!SKhLc^eJSwMIA@fS_(=@#10_inUOUUXwrcZC;&_%NAnb+0Potx% z2ee)II~ebn}y?KX|}Whx_wSpYEQot!?_FojuI7R5mz>HD)SJ%r8o_<*w6|VnOd`gYePiybW{!t7( z8ji0_6oS4%%ZXJ-F{o`H3_Eyhk?}?JynVKA(ep(qhAUGPQoKk?wsMc?3^zco!bP0@ zHWnT&DeD5Z7;x8hhbjr#j+ZXaHk>kdf`-_}7oa~A7xzY2zpHX_Ou@gO7EW=Uq*Rh0 zS2EuW{B*o|P7G!$-@T2ft7ywP$%<04oNnCyLs7x_t#dSs6Zp^~db3>kv0=ow*$iju z_Bb}zK_jpKQXnm#$XDWq_6$B#)_dLd^F_?5W-*Wk>Ab;O+mh*o1i`L__EJ%!|ABjj z_D6=^`UOl|G*)`-&i-OIF)9Y;q6nTCELK5xFgw~x*9&)TkmYMC=i2L>F1Zp`AB=9Yt5Z)Bui#65aSY%X| zrR>R{aBEVt&GA;18fMJsP89{}=`C&3GV0AjsL!}{&ULAXu zEj*hr@tld7u#G;_h6eSyZgO|Qa`yEP9u`bd0g-!4+1EeE_`T@xW}{uV8e1+4=Psrr zYcYevT$ZPF!ZR3#3dBtRY{$rCivFB{<%7S#)Ra%W*_oUx25nW)fgTkz zdYm4m%A93IrnAm%t%v6*1^9Da@7&2V(~z6O@3u5{37HrU5>~1lHID8C?jg!Q#0Uka zE-}>_~hM~fvc(B zb4Cu&mVG(YE@jQ2=QG-n-pK3G{k*p^o@U6QRM3;V!TnNkaa`XwDrVSqR<8_ z2yd4CY$wZrB;mP2e zomJ(N&Z(|#kHkR!&F-bP2=@85F#mW;+!ODIDOzFq9w*yxJJ`cB6$O|W{wtS(F|~0! zs(N{6%(IV|Ee&ZzmF8D+7l_cLu0zR0r+YVe3-$gg+q&zgb)8^q?G7OEh^=P$j7lhv z`0hpCo>RrFo30~ByGgW~ZDD3LYSQNE{9e<18~E~;N`fzI0WFwkH~?CtN&Pp1$VyDN z6l86AO((F}<^j|^%O?IBB~j>HR|JXOT7bJ5=yN~LY}(R0F*eO^npAO=+KsCsVAG=b zvs4s6S2I{(to#|9<+>_zU25LfoF$JA>N9*6$*|0=vgmuytgsSxALf58^{{=+A-_)L vaYtq*Uhhe!wlz|?9t?erbEYExmof26DEeHx<{xj?fpdaY{hQCb_~l;#M&`!= literal 0 HcmV?d00001 diff --git a/test/testdata/snapshots/TestSnapshotShouldFail.golden b/test/testdata/snapshots/TestSnapshotShouldFail.golden new file mode 100644 index 0000000..639b6df --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotShouldFail.golden @@ -0,0 +1,7 @@ +(struct { A string; B int; C bool; D *string }) { + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} +(string) (len=12) "Hello World!" diff --git a/test/testdata/snapshots/TestSnapshotSkipFields.golden b/test/testdata/snapshots/TestSnapshotSkipFields.golden new file mode 100644 index 0000000..eae0b39 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotSkipFields.golden @@ -0,0 +1,7 @@ +(struct { ID string; A string; B int; C bool; D *string }) { + ID: , + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} diff --git a/test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden b/test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden new file mode 100644 index 0000000..64612b3 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden @@ -0,0 +1,9 @@ +(struct { ID string; A string; B int; C bool; D interface {}; E []string; F map[string]int }) { + ID: , + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (struct { Foo string; Bar int }) { }, + E: ([]string) (len=2) { }, + F: (map[string]int) (len=3) { } +} diff --git a/test/testdata/snapshots/TestSnapshotSkipPrefixedFields.golden b/test/testdata/snapshots/TestSnapshotSkipPrefixedFields.golden new file mode 100644 index 0000000..a23c073 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotSkipPrefixedFields.golden @@ -0,0 +1,10 @@ +(struct { ID string; OtherIDStr string; OtherIDInt int; OtherIDBool bool; OtherIDPTR *string; OtherIDStruct struct { ID string } }) { + ID: , + OtherIDStr: (string) (len=6) "id str", + OtherIDInt: (int) 4, + OtherIDBool: (bool) true, + OtherIDPTR: (*string)((len=10) "ID str ptr"), + OtherIDStruct: (struct { ID string }) { + ID: , + } +} diff --git a/test/testdata/snapshots/TestSnapshotWithLabel_A.golden b/test/testdata/snapshots/TestSnapshotWithLabel_A.golden new file mode 100644 index 0000000..66fe1a0 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotWithLabel_A.golden @@ -0,0 +1,6 @@ +(struct { A string; B int; C bool; D *string }) { + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} diff --git a/test/testdata/snapshots/TestSnapshotWithLabel_B.golden b/test/testdata/snapshots/TestSnapshotWithLabel_B.golden new file mode 100644 index 0000000..ecd5966 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotWithLabel_B.golden @@ -0,0 +1 @@ +(string) (len=12) "Hello World!" diff --git a/test/testdata/snapshots/TestSnapshotWithReplacer.golden b/test/testdata/snapshots/TestSnapshotWithReplacer.golden new file mode 100644 index 0000000..eae0b39 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotWithReplacer.golden @@ -0,0 +1,7 @@ +(struct { ID string; A string; B int; C bool; D *string }) { + ID: , + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} diff --git a/test/testdata/snapshots/TestSnapshotWithUpdate.golden b/test/testdata/snapshots/TestSnapshotWithUpdate.golden new file mode 100644 index 0000000..04fbf58 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotWithUpdate.golden @@ -0,0 +1,7 @@ +(struct { A string; B int; C bool; D *string }) { + A: (string) (len=2) "fo", + B: (int) 1, + C: (bool) true, + D: (*string)((len=3) "bar") +} +(string) (len=12) "Hello World!" diff --git a/test/testdata/snapshots/TestWhereInArgs.golden b/test/testdata/snapshots/TestWhereInArgs.golden new file mode 100644 index 0000000..c00f1bc --- /dev/null +++ b/test/testdata/snapshots/TestWhereInArgs.golden @@ -0,0 +1,5 @@ +(pq.StringArray) (len=3) { + (string) (len=3) "max", + (string) (len=6) "muster", + (string) (len=5) "peter" +} diff --git a/test/testdata/snapshots/TestWhereInSQL.golden b/test/testdata/snapshots/TestWhereInSQL.golden new file mode 100644 index 0000000..2b0c18a --- /dev/null +++ b/test/testdata/snapshots/TestWhereInSQL.golden @@ -0,0 +1 @@ +(string) (len=134) "SELECT * FROM \"users\" INNER JOIN app_user_profiles ON app_user_profiles.user_id=users.id WHERE (app_user_profiles.username = any($1));" diff --git a/test/testdata/snapshots/TestWhereJSONStringArgs.golden b/test/testdata/snapshots/TestWhereJSONStringArgs.golden new file mode 100644 index 0000000..7a897cf --- /dev/null +++ b/test/testdata/snapshots/TestWhereJSONStringArgs.golden @@ -0,0 +1 @@ +(string) (len=37) "https://example.org/users/123/profile" diff --git a/test/testdata/snapshots/TestWhereJSONStringSQL.golden b/test/testdata/snapshots/TestWhereJSONStringSQL.golden new file mode 100644 index 0000000..50b98d0 --- /dev/null +++ b/test/testdata/snapshots/TestWhereJSONStringSQL.golden @@ -0,0 +1 @@ +(string) (len=55) "SELECT * FROM \"users\" WHERE (users.profile::text = $1);" diff --git a/test/testdata/snapshots/TestWhereJSONStructArgs.golden b/test/testdata/snapshots/TestWhereJSONStructArgs.golden new file mode 100644 index 0000000..c67e86d --- /dev/null +++ b/test/testdata/snapshots/TestWhereJSONStructArgs.golden @@ -0,0 +1,14 @@ +(string) (len=3) "Max" +(string) (len=6) "Muster" +(string) (len=7) "Austria" +(*pq.StringArray)((len=2) { + (string) (len=3) "app", + (string) (len=9) "user_info" +}) +(int) 42 +(pq.GenericArray) { + A: ([2]string) (len=2) { + (string) (len=15) "+1 206 555 0100", + (string) (len=16) "+44 113 496 0000" + } +} diff --git a/test/testdata/snapshots/TestWhereJSONStructCompositionArgs.golden b/test/testdata/snapshots/TestWhereJSONStructCompositionArgs.golden new file mode 100644 index 0000000..8bd60f5 --- /dev/null +++ b/test/testdata/snapshots/TestWhereJSONStructCompositionArgs.golden @@ -0,0 +1,8 @@ +(string) (len=3) "Max" +(string) (len=6) "Muster" +(string) (len=7) "Austria" +(*pq.StringArray)((len=2) { + (string) (len=3) "app", + (string) (len=9) "user_info" +}) +(int) 42 diff --git a/test/testdata/snapshots/TestWhereJSONStructCompositionSQL.golden b/test/testdata/snapshots/TestWhereJSONStructCompositionSQL.golden new file mode 100644 index 0000000..ec734f4 --- /dev/null +++ b/test/testdata/snapshots/TestWhereJSONStructCompositionSQL.golden @@ -0,0 +1 @@ +(string) (len=217) "SELECT * FROM \"users\" WHERE (users.profile->>'firstName' = $1 AND users.profile->>'lastName' = $2 AND users.profile->>'country' = $3 AND users.profile->'scopes' <@ to_jsonb($4::text[]) AND users.profile->>'age' = $5);" diff --git a/test/testdata/snapshots/TestWhereJSONStructSQL.golden b/test/testdata/snapshots/TestWhereJSONStructSQL.golden new file mode 100644 index 0000000..b24188d --- /dev/null +++ b/test/testdata/snapshots/TestWhereJSONStructSQL.golden @@ -0,0 +1 @@ +(string) (len=275) "SELECT * FROM \"users\" WHERE (users.profile->>'firstName' = $1 AND users.profile->>'lastName' = $2 AND users.profile->>'country' = $3 AND users.profile->'scopes' <@ to_jsonb($4::text[]) AND users.profile->>'age' = $5 AND users.profile->'phoneNumbers' <@ to_jsonb($6::text[]));" diff --git a/test/testdata/users.sql b/test/testdata/users.sql new file mode 100644 index 0000000..b693f34 --- /dev/null +++ b/test/testdata/users.sql @@ -0,0 +1,90 @@ +-- +-- PostgreSQL database dump +-- +-- Dumped from database version 12.4 +-- Dumped by pg_dump version 12.4 +SET statement_timeout = 0; + +SET lock_timeout = 0; + +SET idle_in_transaction_session_timeout = 0; + +SET client_encoding = 'UTF8'; + +SET standard_conforming_strings = ON; + +SELECT + pg_catalog.set_config('search_path', '', FALSE); + +SET check_function_bodies = FALSE; + +SET xmloption = content; + +SET client_min_messages = warning; + +SET row_security = OFF; + +ALTER TABLE IF EXISTS ONLY public.users + DROP CONSTRAINT IF EXISTS users_username_key; + +ALTER TABLE IF EXISTS ONLY public.users + DROP CONSTRAINT IF EXISTS users_pkey; + +DROP TABLE IF EXISTS public.users; + +DROP EXTENSION IF EXISTS "uuid-ossp"; + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: dbuser +-- +CREATE TABLE public.users ( + id uuid DEFAULT public.uuid_generate_v4 () NOT NULL, + username character varying(255), + password text, + is_active boolean NOT NULL, + scopes text[] NOT NULL, + last_authenticated_at timestamp with time zone, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +ALTER TABLE public.users OWNER TO dbuser; + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: dbuser +-- +COPY public.users (id, username, password, is_active, scopes, last_authenticated_at, created_at, updated_at) FROM stdin; +44a4b372-9d45-42a7-a4bd-9ba78b580e09 test_user1@example.com $argon2id$v=19$m=65536,t=1,p=4$bM/6DaUMUQlr8CPYHOcFwA$Cq0p8d3IKEy1+G3VfHRXDRdc15wHJtTx+UGsXD6bbWY t {app} 2020-09-16 14:39:34.098333+00 2020-09-16 12:37:27.034337+00 2020-09-16 14:39:34.098334+00 +738a3a72-2267-4727-beef-44193491c7d0 test_user2@example.com $argon2id$v=19$m=65536,t=1,p=4$goeC9sTUXfQMpLbdqHbIiA$nadmsRl8d0HTGuKOmjg1WGGOfvJkfPUb8aSj48t7upk t {app} 2020-11-10 13:28:11.259994+00 2020-11-10 13:28:11.259998+00 2020-11-10 13:28:11.259998+00 +8c39db83-c355-4e55-b10a-ac9bf0b15e50 test_user3@example.com $argon2id$v=19$m=65536,t=1,p=4$CWvCxZhldc/UmlZaHje7jg$igRKSJjHxlUR/5l21FX1aQIGbm+1J30/L1fLQGduy/U t {app} 2021-02-24 13:57:40.870073+00 2021-02-24 13:57:40.870076+00 2021-02-24 13:57:40.870076+00 +\. + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: dbuser +-- +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +-- +-- Name: users users_username_key; Type: CONSTRAINT; Schema: public; Owner: dbuser +-- +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_username_key UNIQUE (username); + +-- +-- PostgreSQL database dump complete +-- diff --git a/test/testdata/uuid_extension_only.sql b/test/testdata/uuid_extension_only.sql new file mode 100644 index 0000000..d501a78 --- /dev/null +++ b/test/testdata/uuid_extension_only.sql @@ -0,0 +1,69 @@ +-- +-- PostgreSQL database dump +-- +-- Dumped from database version 12.4 +-- Dumped by pg_dump version 12.10 (Debian 12.10-1.pgdg100+1) +SET statement_timeout = 0; + +SET lock_timeout = 0; + +SET idle_in_transaction_session_timeout = 0; + +SET client_encoding = 'UTF8'; + +SET standard_conforming_strings = ON; + +SELECT + pg_catalog.set_config('search_path', '', FALSE); + +SET check_function_bodies = FALSE; + +SET xmloption = content; + +SET client_min_messages = warning; + +SET row_security = OFF; + +ALTER TABLE IF EXISTS ONLY public.gorp_migrations + DROP CONSTRAINT IF EXISTS gorp_migrations_pkey; + +DROP TABLE IF EXISTS public.gorp_migrations; + +DROP EXTENSION IF EXISTS "uuid-ossp"; + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + +-- +-- Name: gorp_migrations; Type: TABLE; Schema: public; Owner: dbuser +-- +CREATE TABLE public.gorp_migrations ( + id text NOT NULL, + applied_at timestamp with time zone +); + +ALTER TABLE public.gorp_migrations OWNER TO dbuser; + +-- +-- Data for Name: gorp_migrations; Type: TABLE DATA; Schema: public; Owner: dbuser +-- +COPY public.gorp_migrations (id, applied_at) FROM stdin; +20200428064736-install-extension-uuid.sql 2022-03-28 17:04:00.580862+00 +\. + +-- +-- Name: gorp_migrations gorp_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: dbuser +-- +ALTER TABLE ONLY public.gorp_migrations + ADD CONSTRAINT gorp_migrations_pkey PRIMARY KEY (id); + +-- +-- PostgreSQL database dump complete +-- diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..c4ce365 --- /dev/null +++ b/web/README.md @@ -0,0 +1,13 @@ +# `/web` + +Web application specific components: static web assets, server side templates and SPAs. + +https://github.com/golang-standards/project-layout/tree/master/web + +### `/web/i18n` + +Please name your translation files according to the locale (e.g. `de.toml`, `en.toml` or `en-uk.toml` and `en-us.toml`). We assume that any translation file hold all keys (no key mixing between locales)! + +### `/web/templates` + +This directory should e.g. hold email related templates (used by `/internal/mailer`). \ No newline at end of file diff --git a/web/i18n/en.toml b/web/i18n/en.toml new file mode 100644 index 0000000..1dd34ac --- /dev/null +++ b/web/i18n/en.toml @@ -0,0 +1,3 @@ +# https://github.com/toml-lang/toml/wiki +# https://github.com/nicksnyder/go-i18n +# Add additional files (like de.toml) or more specialized language forms like (en-uk.toml) into this folder. \ No newline at end of file diff --git a/web/templates/email/account_confirmation/account_confirmation.html.tmpl b/web/templates/email/account_confirmation/account_confirmation.html.tmpl new file mode 100644 index 0000000..acc25c4 --- /dev/null +++ b/web/templates/email/account_confirmation/account_confirmation.html.tmpl @@ -0,0 +1,10 @@ + + + + + Account confirmation + + + Click here + + \ No newline at end of file diff --git a/web/templates/email/password_reset/password_reset.html.tmpl b/web/templates/email/password_reset/password_reset.html.tmpl new file mode 100644 index 0000000..1a65e8c --- /dev/null +++ b/web/templates/email/password_reset/password_reset.html.tmpl @@ -0,0 +1,10 @@ + + + + + Password reset + + + Click here + + \ No newline at end of file diff --git a/web/templates/views/account_confirmation.html.tmpl b/web/templates/views/account_confirmation.html.tmpl new file mode 100644 index 0000000..c1bc9f9 --- /dev/null +++ b/web/templates/views/account_confirmation.html.tmpl @@ -0,0 +1,30 @@ + + + + + Account confirmation + + +
Processing request...
+ + + + \ No newline at end of file