Compare commits
183 Commits
cli@1.32.0
...
storage@0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438355bff0 | ||
|
|
62b97838fe | ||
|
|
d287191f7a | ||
|
|
b8d2127b06 | ||
|
|
28cec232c8 | ||
|
|
fe853da133 | ||
|
|
c4445135bf | ||
|
|
db7366dfc7 | ||
|
|
31c503e458 | ||
|
|
187d35412e | ||
|
|
4c93094e4d | ||
|
|
2ba53e4fef | ||
|
|
23bd2f8d4f | ||
|
|
a14d1e4f22 | ||
|
|
7509bd8a96 | ||
|
|
0b560dcb52 | ||
|
|
51619dbf87 | ||
|
|
40707e534e | ||
|
|
75a508afe8 | ||
|
|
a7353a83fd | ||
|
|
c2b5a499af | ||
|
|
b0a2ceb368 | ||
|
|
a957f4051a | ||
|
|
47bd415a97 | ||
|
|
730948f07b | ||
|
|
818654f8ef | ||
|
|
6790c7d08f | ||
|
|
5f1a23960a | ||
|
|
e4da899d59 | ||
|
|
b2dc2dd8f9 | ||
|
|
c21d1d4547 | ||
|
|
e0c0709d1e | ||
|
|
449f1c58cf | ||
|
|
97eb40b2a2 | ||
|
|
d2e05005ed | ||
|
|
168c433729 | ||
|
|
066bc1df6d | ||
|
|
f1bc6f8e5c | ||
|
|
32bec88d4c | ||
|
|
aa51d402bd | ||
|
|
218a310641 | ||
|
|
c2c86d5b43 | ||
|
|
1417ea7209 | ||
|
|
92b3d6662a | ||
|
|
279714c790 | ||
|
|
2cc8616288 | ||
|
|
cd872682db | ||
|
|
e23a069a43 | ||
|
|
5024de8ecb | ||
|
|
c8a7bcee75 | ||
|
|
843ea6b321 | ||
|
|
0d5f5ed0e1 | ||
|
|
43644e8062 | ||
|
|
c5c23f14fd | ||
|
|
e8e78dd422 | ||
|
|
5c4068dd74 | ||
|
|
95eaac8e39 | ||
|
|
9304c39b97 | ||
|
|
19d678ce26 | ||
|
|
51ccc70282 | ||
|
|
3bdbf46a3e | ||
|
|
4d226c5b83 | ||
|
|
97a03dcfce | ||
|
|
26572176d3 | ||
|
|
07d48bc121 | ||
|
|
e18f4e0be0 | ||
|
|
d88ba6b17a | ||
|
|
3de9a2d09b | ||
|
|
074d1e1eaf | ||
|
|
cf652fc168 | ||
|
|
4c2318e9d4 | ||
|
|
8feb508ea1 | ||
|
|
996c8c2dff | ||
|
|
e7dc5b3cee | ||
|
|
f9bc7fa5c3 | ||
|
|
752c725750 | ||
|
|
c89b48d851 | ||
|
|
c5f688fb65 | ||
|
|
f32b7846d1 | ||
|
|
a8e9c1d32a | ||
|
|
b7e3151702 | ||
|
|
78447dd391 | ||
|
|
5bd777d6c2 | ||
|
|
3235e38b20 | ||
|
|
b1ce4703d0 | ||
|
|
771dca1064 | ||
|
|
2f5c47ec84 | ||
|
|
759e6a53c6 | ||
|
|
b9aebcb47c | ||
|
|
be5e5f123f | ||
|
|
4b801e5b95 | ||
|
|
f0c3768f62 | ||
|
|
8e342e4520 | ||
|
|
315e90dd5c | ||
|
|
fe1de57395 | ||
|
|
7381b3c72d | ||
|
|
4282ea68ca | ||
|
|
6ff55c24b6 | ||
|
|
744d7394e0 | ||
|
|
9349d30889 | ||
|
|
5b8ac94b82 | ||
|
|
656d563ca4 | ||
|
|
097abb4617 | ||
|
|
da8215a1c2 | ||
|
|
9da2e8c4e1 | ||
|
|
7dbdc85ec0 | ||
|
|
1b6fcb1573 | ||
|
|
a9f6b098a0 | ||
|
|
2eb2eb7d81 | ||
|
|
7500dd4d61 | ||
|
|
85989f47ba | ||
|
|
0f597c1c0c | ||
|
|
fd6bba889f | ||
|
|
c6151c9638 | ||
|
|
70c0cc53bb | ||
|
|
f32a0b1a1d | ||
|
|
0ea78379af | ||
|
|
1323f5c73b | ||
|
|
0520dcbbbc | ||
|
|
6f9bcf4564 | ||
|
|
425946137c | ||
|
|
91d9e4d41a | ||
|
|
56c641f6e7 | ||
|
|
789f4a2e91 | ||
|
|
1b3cfeff4e | ||
|
|
d17dc3ed80 | ||
|
|
b4b4fb4eea | ||
|
|
de48ecdc28 | ||
|
|
d8e68e59e7 | ||
|
|
2251e3a598 | ||
|
|
de06fca938 | ||
|
|
72bc77de7f | ||
|
|
88c5b49f00 | ||
|
|
52fbd758cb | ||
|
|
e013c3b09e | ||
|
|
7ce5fdf39e | ||
|
|
c89df28521 | ||
|
|
8c8b2d7c6d | ||
|
|
f230ee75cb | ||
|
|
a04e154bf5 | ||
|
|
344870319c | ||
|
|
c96d4cdcbe | ||
|
|
383920d593 | ||
|
|
e1ea387598 | ||
|
|
e5857bc131 | ||
|
|
8d061f712c | ||
|
|
dd7bdccf24 | ||
|
|
de2a54c519 | ||
|
|
b0777bd423 | ||
|
|
92313744ce | ||
|
|
925fa51886 | ||
|
|
9db23f87d7 | ||
|
|
38fe19b482 | ||
|
|
03cabdbe86 | ||
|
|
d3fab91585 | ||
|
|
3ccd04f102 | ||
|
|
cd4fcb1aa0 | ||
|
|
06183bcab5 | ||
|
|
8579baed75 | ||
|
|
56615c1341 | ||
|
|
eeba558231 | ||
|
|
d08699f283 | ||
|
|
86a41734d9 | ||
|
|
195cee0572 | ||
|
|
787fa0a069 | ||
|
|
678dd96238 | ||
|
|
3c1d3528db | ||
|
|
8327869801 | ||
|
|
37245a0d5d | ||
|
|
a7e10b5b9a | ||
|
|
c0a37bf1ce | ||
|
|
da427a37c9 | ||
|
|
7e2c14f147 | ||
|
|
e565498a6d | ||
|
|
603f6dae48 | ||
|
|
5771d2252c | ||
|
|
ef3d382f2b | ||
|
|
f46599d675 | ||
|
|
0302644cf8 | ||
|
|
9acf2b1f89 | ||
|
|
4a9ad0f082 | ||
|
|
c61ea9bef6 | ||
|
|
fcc44652f2 |
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -32,6 +32,7 @@ Where `PKG` is:
|
||||
- `mintlify-openapi`: For changes to the Mintlify OpenAPI tool
|
||||
- `nhost-js`: For changes to the Nhost JavaScript SDK
|
||||
- `nixops`: For changes to the NixOps
|
||||
- `storage`: For changes to the Nhost Storage
|
||||
|
||||
Where `SUMMARY` is a short description of what the PR does.
|
||||
|
||||
|
||||
15
.github/actions/cache-nix/action.yml
vendored
Normal file
15
.github/actions/cache-nix/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: 'Cache Nix to S3'
|
||||
description: 'Copy Nix store to S3-backed cache'
|
||||
inputs:
|
||||
NIX_CACHE_PRIV_KEY:
|
||||
description: 'Nix cache private key'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: "Cache build"
|
||||
shell: bash
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ inputs.NIX_CACHE_PRIV_KEY }}") --all
|
||||
nix copy --to 's3://nhost-nix-cache?region=eu-central-1' --substitute-on-destination --all
|
||||
51
.github/actions/setup-nix/action.yml
vendored
Normal file
51
.github/actions/setup-nix/action.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: 'Setup Nix'
|
||||
description: 'Install Nix and setup caching for Nhost projects'
|
||||
inputs:
|
||||
NAME:
|
||||
description: 'Project name for cache key'
|
||||
required: true
|
||||
NIX_CACHE_PUB_KEY:
|
||||
description: 'Nix cache public key'
|
||||
required: true
|
||||
GITHUB_TOKEN:
|
||||
description: 'GitHub token for Nix access'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ inputs.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ inputs.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
|
||||
# - name: "Verify if nixops is pre-built"
|
||||
# id: verify-nixops-build
|
||||
# run: |
|
||||
# export drvPath=$(make build-nixops-dry-run)
|
||||
# echo "Derivation path: $drvPath"
|
||||
# nix path-info --store s3://nhost-nix-cache\?region=eu-central-1 $drvPath \
|
||||
# || (echo "Wait until nixops is already built and cached and run again" && exit 1)
|
||||
# if: ${{ inputs.NAME != 'nixops' }}
|
||||
|
||||
14
.github/dependabot.yaml
vendored
Normal file
14
.github/dependabot.yaml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
open-pull-requests-limit: 10
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "04:00"
|
||||
commit-message:
|
||||
prefix: "chore(ci)"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github_actions"
|
||||
- "chore"
|
||||
28
.github/workflows/ci_create_release.yaml
vendored
28
.github/workflows/ci_create_release.yaml
vendored
@@ -24,30 +24,12 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-cliff-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-cliff-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-cliff-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Extract project and version from PR title"
|
||||
id: extract
|
||||
|
||||
16
.github/workflows/ci_release.yaml
vendored
16
.github/workflows/ci_release.yaml
vendored
@@ -38,7 +38,6 @@ jobs:
|
||||
if: needs.extract-project.outputs.project == 'cli'
|
||||
uses: ./.github/workflows/cli_release.yaml
|
||||
with:
|
||||
NAME: dashboard
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||
secrets:
|
||||
@@ -54,7 +53,6 @@ jobs:
|
||||
if: needs.extract-project.outputs.project == '@nhost/dashboard'
|
||||
uses: ./.github/workflows/dashboard_release.yaml
|
||||
with:
|
||||
NAME: dashboard
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||
secrets:
|
||||
@@ -83,3 +81,17 @@ jobs:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_PRODUCTION }}
|
||||
|
||||
storage:
|
||||
needs: extract-project
|
||||
if: needs.extract-project.outputs.project == 'storage'
|
||||
uses: ./.github/workflows/storage_release.yaml
|
||||
with:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
30
.github/workflows/ci_update_changelog.yaml
vendored
30
.github/workflows/ci_update_changelog.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
project: [cli, dashboard, packages/nhost-js]
|
||||
project: [cli, dashboard, packages/nhost-js, services/storage]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -27,30 +27,12 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-cliff-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-cliff-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-cliff-${{ runner.os }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Get next version"
|
||||
id: version
|
||||
|
||||
39
.github/workflows/cli_release.yaml
vendored
39
.github/workflows/cli_release.yaml
vendored
@@ -3,9 +3,6 @@ name: "cli: release"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
NAME:
|
||||
required: true
|
||||
type: string
|
||||
GIT_REF:
|
||||
required: true
|
||||
type: string
|
||||
@@ -76,30 +73,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: cli
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute common env vars
|
||||
id: vars
|
||||
@@ -136,8 +115,8 @@ jobs:
|
||||
--clobber "${{ github.ref_name }}" \
|
||||
./upload/* checksums.txt
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
- name: "Store Nix cache"
|
||||
uses: ./.github/actions/cache-nix
|
||||
with:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
if: always()
|
||||
|
||||
37
.github/workflows/cli_test_new_project.yaml
vendored
37
.github/workflows/cli_test_new_project.yaml
vendored
@@ -55,30 +55,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Get artifacts"
|
||||
uses: actions/download-artifact@v5
|
||||
@@ -108,9 +90,8 @@ jobs:
|
||||
/home/runner/_work/nhost/nhost/cli/result/bin/cli up --down-on-error
|
||||
/home/runner/_work/nhost/nhost/cli/result/bin/cli down --volumes
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
- name: "Store Nix cache"
|
||||
uses: ./.github/actions/cache-nix
|
||||
with:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
if: always()
|
||||
|
||||
|
||||
3
.github/workflows/dashboard_release.yaml
vendored
3
.github/workflows/dashboard_release.yaml
vendored
@@ -4,9 +4,6 @@ name: 'dashboard: release'
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
NAME:
|
||||
required: true
|
||||
type: string
|
||||
GIT_REF:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
11
.github/workflows/gen_ai_review.yaml
vendored
11
.github/workflows/gen_ai_review.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
if: ${{ github.event.sender.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -16,12 +16,11 @@ jobs:
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@v0.26
|
||||
uses: Codium-ai/pr-agent@v0.30
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
config.max_model_tokens: 100000
|
||||
config.model: "anthropic/claude-3-5-sonnet-20240620"
|
||||
config.model_turbo: "anthropic/claude-3-5-sonnet-20240620"
|
||||
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml']"
|
||||
config.model: "anthropic/claude-sonnet-4-20250514"
|
||||
config.model_turbo: "anthropic/claude-sonnet-4-20250514"
|
||||
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml', 'vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
|
||||
|
||||
80
.github/workflows/storage_checks.yaml
vendored
Normal file
80
.github/workflows/storage_checks.yaml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: "storage: check and build"
|
||||
on:
|
||||
# pull_request_target:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/storage_checks.yaml'
|
||||
- '.github/workflows/wf_check.yaml'
|
||||
- '.github/workflows/wf_build_artifacts.yaml'
|
||||
|
||||
# common build
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'nixops/**'
|
||||
- 'build/**'
|
||||
|
||||
# common go
|
||||
- '.golangci.yaml'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'vendor/**'
|
||||
|
||||
# storage
|
||||
- 'storage/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "github.event_name: ${{ github.event_name }}"
|
||||
echo "github.event.pull_request.author_association: ${{ github.event.pull_request.author_association }}"
|
||||
- name: "This task will run and fail if user has no permissions and label safe_to_test isn't present"
|
||||
if: "github.event_name == 'pull_request_target' && ! ( contains(github.event.pull_request.labels.*.name, 'safe_to_test') || contains(fromJson('[\"OWNER\", \"MEMBER\", \"COLLABORATOR\"]'), github.event.pull_request.author_association) )"
|
||||
run: |
|
||||
exit 1
|
||||
|
||||
tests:
|
||||
uses: ./.github/workflows/wf_check.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
with:
|
||||
NAME: storage
|
||||
PATH: services/storage
|
||||
GIT_REF: ${{ github.sha }}
|
||||
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||
|
||||
build_artifacts:
|
||||
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
with:
|
||||
NAME: storage
|
||||
PATH: services/storage
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||
DOCKER: true
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
|
||||
remove_label:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-permissions
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-remove-labels@v1
|
||||
with:
|
||||
labels: |
|
||||
safe_to_test
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe_to_test')
|
||||
60
.github/workflows/storage_release.yaml
vendored
Normal file
60
.github/workflows/storage_release.yaml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: "storage: release"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
GIT_REF:
|
||||
required: true
|
||||
type: string
|
||||
VERSION:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID:
|
||||
required: true
|
||||
NIX_CACHE_PUB_KEY:
|
||||
required: true
|
||||
NIX_CACHE_PRIV_KEY:
|
||||
required: true
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build_artifacts:
|
||||
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||
with:
|
||||
NAME: storage
|
||||
PATH: services/storage
|
||||
GIT_REF: ${{ inputs.GIT_REF }}
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
DOCKER: true
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
|
||||
push-docker-hub:
|
||||
uses: ./.github/workflows/wf_docker_push_image.yaml
|
||||
needs:
|
||||
- build_artifacts
|
||||
with:
|
||||
NAME: storage
|
||||
PATH: services/storage
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
push-docker-ecr:
|
||||
uses: ./.github/workflows/wf_docker_push_image_ecr.yaml
|
||||
needs:
|
||||
- build_artifacts
|
||||
with:
|
||||
NAME: storage
|
||||
PATH: services/storage
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
|
||||
CONTAINER_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-central-1.amazonaws.com
|
||||
45
.github/workflows/wf_build_artifacts.yaml
vendored
45
.github/workflows/wf_build_artifacts.yaml
vendored
@@ -55,39 +55,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
|
||||
# - name: "Verify if nixops is pre-built"
|
||||
# id: verify-nixops-build
|
||||
# run: |
|
||||
# export drvPath=$(make build-nixops-dry-run)
|
||||
# echo "Derivation path: $drvPath"
|
||||
# nix path-info --store s3://nhost-nix-cache\?region=eu-central-1 $drvPath \
|
||||
# || (echo "Wait until nixops is already built and cached and run again" && exit 1)
|
||||
# if: ${{ inputs.NAME != 'nixops' }}
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute common env vars
|
||||
id: vars
|
||||
@@ -124,8 +97,8 @@ jobs:
|
||||
retention-days: 7
|
||||
if: ${{ ( inputs.DOCKER ) }}
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
- name: "Store Nix cache"
|
||||
uses: ./.github/actions/cache-nix
|
||||
with:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
if: always()
|
||||
|
||||
45
.github/workflows/wf_check.yaml
vendored
45
.github/workflows/wf_check.yaml
vendored
@@ -55,39 +55,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
|
||||
# - name: "Verify if nixops is pre-built"
|
||||
# id: verify-nixops-build
|
||||
# run: |
|
||||
# export drvPath=$(make build-nixops-dry-run)
|
||||
# echo "Derivation path: $drvPath"
|
||||
# nix path-info --store s3://nhost-nix-cache\?region=eu-central-1 $drvPath \
|
||||
# || (echo "Wait until nixops is already built and cached and run again" && exit 1)
|
||||
# if: ${{ inputs.NAME != 'nixops' }}
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Verify if we need to build"
|
||||
id: verify-build
|
||||
@@ -109,8 +82,8 @@ jobs:
|
||||
run: make check
|
||||
if: ${{ steps.verify-build.outputs.BUILD_NEEDED == 'yes' }}
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
- name: "Store Nix cache"
|
||||
uses: ./.github/actions/cache-nix
|
||||
with:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
if: always()
|
||||
|
||||
36
.github/workflows/wf_dashboard_e2e_staging.yaml
vendored
36
.github/workflows/wf_dashboard_e2e_staging.yaml
vendored
@@ -106,30 +106,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Start CLI
|
||||
run: |
|
||||
@@ -162,8 +144,8 @@ jobs:
|
||||
path: dashboard/playwright-report.tar.gz.enc
|
||||
retention-days: 1
|
||||
|
||||
- name: "Cache build"
|
||||
run: |
|
||||
nix store sign --key-file <(echo "${{ secrets.NIX_CACHE_PRIV_KEY }}") --all
|
||||
find /nix/store -maxdepth 1 -name "*-*" -type d | xargs -n 25 nix copy --to s3://nhost-nix-cache\?region=eu-central-1
|
||||
- name: "Store Nix cache"
|
||||
uses: ./.github/actions/cache-nix
|
||||
with:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
if: always()
|
||||
|
||||
28
.github/workflows/wf_deploy_vercel.yaml
vendored
28
.github/workflows/wf_deploy_vercel.yaml
vendored
@@ -57,30 +57,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Vercel deployment
|
||||
id: deploy
|
||||
|
||||
84
.github/workflows/wf_docker_push_image_ecr.yaml
vendored
Normal file
84
.github/workflows/wf_docker_push_image_ecr.yaml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
NAME:
|
||||
type: string
|
||||
required: true
|
||||
PATH:
|
||||
type: string
|
||||
required: true
|
||||
VERSION:
|
||||
type: string
|
||||
required: true
|
||||
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID:
|
||||
required: true
|
||||
CONTAINER_REGISTRY:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
push-to-registry:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.PATH }}
|
||||
|
||||
steps:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Configure aws
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- name: "Login to Amazon ECR"
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
with:
|
||||
mask-password: 'true'
|
||||
|
||||
- name: "Compute common env vars"
|
||||
id: vars
|
||||
run: |
|
||||
echo "VERSION=$(make get-version VER=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Get artifacts"
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: ~/artifacts
|
||||
|
||||
- name: "Inspect artifacts"
|
||||
run: find ~/artifacts
|
||||
|
||||
- name: "Push docker image to docker hub"
|
||||
run: |
|
||||
export NAME=${{ inputs.NAME }}
|
||||
export VERSION=${{ steps.vars.outputs.VERSION }}
|
||||
export CONTAINER_REGISTRY=${{ secrets.CONTAINER_REGISTRY }}
|
||||
export CONTAINER_NAME=$CONTAINER_REGISTRY/$NAME
|
||||
|
||||
for ARCH in "x86_64" "aarch64"; do
|
||||
skopeo copy --insecure-policy \
|
||||
dir:/home/runner/artifacts/${{ inputs.NAME }}-docker-image-$ARCH-$VERSION \
|
||||
docker-daemon:$CONTAINER_NAME:$VERSION-$ARCH
|
||||
docker push $CONTAINER_NAME:$VERSION-$ARCH
|
||||
done
|
||||
|
||||
docker manifest create \
|
||||
$CONTAINER_NAME:$VERSION \
|
||||
--amend $CONTAINER_NAME:$VERSION-x86_64 \
|
||||
--amend $CONTAINER_NAME:$VERSION-aarch64
|
||||
|
||||
docker manifest push $CONTAINER_NAME:$VERSION
|
||||
28
.github/workflows/wf_release_npm.yaml
vendored
28
.github/workflows/wf_release_npm.yaml
vendored
@@ -47,30 +47,12 @@ jobs:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Setup Nix with Cache
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
install_url: "https://releases.nixos.org/nix/nix-2.28.4/install"
|
||||
install_options: "--no-daemon"
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
sandbox = false
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ inputs.NAME }}-${{ runner.os }}-${{ runner.arch }}}-
|
||||
gc-max-store-size-linux: 2G
|
||||
purge: true
|
||||
purge-prefixes: nix-${{ inputs.NAME }}-
|
||||
purge-created: 0
|
||||
purge-last-accessed: 0
|
||||
purge-primary-key: never
|
||||
NAME: ${{ inputs.NAME }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Build package"
|
||||
run: make build
|
||||
|
||||
@@ -26,6 +26,7 @@ linters:
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
# general rules
|
||||
- linters:
|
||||
- funlen
|
||||
- ireturn
|
||||
@@ -40,10 +41,26 @@ linters:
|
||||
- ireturn
|
||||
- lll
|
||||
path: schema\.resolvers\.go
|
||||
|
||||
# storage service specific rules
|
||||
- linters:
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
path: services/storage/cmd/
|
||||
- linters:
|
||||
- gochecknoglobals
|
||||
path: services/storage/cmd/controller/version.go
|
||||
- linters:
|
||||
- funlen
|
||||
- ireturn
|
||||
- exhaustruct
|
||||
path: services/storage/.*_test\.go
|
||||
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
@@ -98,64 +98,3 @@ You can run the e2e tests with the following command from the repository root:
|
||||
```sh
|
||||
$ pnpm e2e
|
||||
```
|
||||
|
||||
## Changesets
|
||||
|
||||
If you've made changes to the packages, you must describe those changes so that they can be reflected in the next release.
|
||||
We use [changesets](https://github.com/changesets/changesets) to support our versioning and release workflows. When you submit a pull request, a bot checks if changesets are present, and if not, it asks you to add them.
|
||||
|
||||
To create a changeset, run the following command from the repository root:
|
||||
|
||||
```sh
|
||||
$ pnpm changeset
|
||||
```
|
||||
|
||||
This command will guide you through the process of creating a changeset. It will create a file in the `.changeset` directory.
|
||||
|
||||
You can take a look at the changeset documentation: [How to add a changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
|
||||
|
||||
### Selecting the Version
|
||||
|
||||
When you create a changeset, you will be asked to select the version of the package that you are bumping. The versioning scheme is as follows:
|
||||
|
||||
- **major**
|
||||
- For breaking changes (e.g: changing the function signature, etc.)
|
||||
- Should be avoided as much as possible as it will require users to update their code. Instead, consider supporting both the old and the new API simultaneously for a while.
|
||||
- For example: `v1.5.8` -> `v2.0.0`
|
||||
- **minor**
|
||||
- For new features (e.g: adding a new page to the dashboard, etc.)
|
||||
- For example: `v1.5.8` -> `v1.6.0`
|
||||
- **patch**
|
||||
- For bug fixes (e.g: fixing a typo, etc.)
|
||||
- For example: `v1.5.8` -> `v1.5.9`
|
||||
|
||||
### Writing Good Changesets
|
||||
|
||||
A concise summary that describes the changes should be added to each PR. This summary will be used as the changeset description.
|
||||
|
||||
The following structure is used for describing changes:
|
||||
|
||||
- **The type of the change**:
|
||||
|
||||
- fix
|
||||
- feat
|
||||
- chore
|
||||
- docs
|
||||
|
||||
- **The scope of the change** (_broader scopes (e.g: dashboard, hasura-storage-js, etc.) are not recommended as GitHub Releases already contain which project is being bumped_):
|
||||
|
||||
- projects
|
||||
- deployments
|
||||
- deps
|
||||
- etc.
|
||||
|
||||
- **A short summary of the changes that were made**
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `fix(deployments): use correct timestamp for deployment details`
|
||||
- `chore(deps): bump @types/react to v18.2.8`
|
||||
- `feat(secrets): enable secrets`
|
||||
- etc.
|
||||
|
||||
You can always take a look at examples of changesets in the [GitHub Releases section](https://github.com/nhost/nhost/releases).
|
||||
|
||||
@@ -101,6 +101,12 @@ test('should create a table with nullable columns', async ({
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
await page.getByText('Edit Table').click();
|
||||
expect(page.locator('h2:has-text("Edit Table")')).toBeVisible();
|
||||
expect(page.locator('div[data-testid="id"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with an identity column', async ({
|
||||
@@ -116,15 +122,15 @@ test('should create a table with an identity column', async ({
|
||||
name: tableName,
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'int4' },
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text', nullable: true },
|
||||
{ name: 'description', type: 'text', nullable: true },
|
||||
{ name: 'identity_column', type: 'int4' },
|
||||
],
|
||||
});
|
||||
|
||||
// await page.getByRole('button', { name: /identity/i }).click();
|
||||
await page.getByLabel('Identity').click();
|
||||
await page.getByRole('option', { name: /id/i }).click();
|
||||
await page.getByRole('option', { name: /identity_column/i }).click();
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
@@ -136,6 +142,17 @@ test('should create a table with an identity column', async ({
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
await page.getByText('Edit Table').click();
|
||||
expect(page.locator('h2:has-text("Edit Table")')).toBeVisible();
|
||||
expect(
|
||||
page.locator('button#identityColumnIndex :has-text("identity_column")'),
|
||||
).toBeVisible();
|
||||
expect(page.locator('[id="columns.3.defaultValue"]')).toBeDisabled();
|
||||
expect(page.locator('[name="columns.3.isNullable"]')).toBeDisabled();
|
||||
expect(page.locator('[name="columns.3.isUnique"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should create table with foreign key constraint', async ({
|
||||
@@ -292,4 +309,11 @@ test('should be able to create a table with a composite key', async ({
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
await page.getByText('Edit Table').click();
|
||||
expect(page.locator('div[data-testid="id"]')).toBeVisible();
|
||||
expect(page.locator('div[data-testid="second_id"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -6,13 +6,14 @@ dotenv.config({ path: path.resolve(__dirname, '.env.test') });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
maxFailures: 1,
|
||||
timeout: 120 * 1000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
|
||||
@@ -17,7 +17,7 @@ import MaterialAutocomplete, {
|
||||
autocompleteClasses as materialAutocompleteClasses,
|
||||
} from '@mui/material/Autocomplete';
|
||||
import clsx from 'clsx';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import type { DetailedHTMLProps, ForwardedRef, HTMLProps } from 'react';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
export interface AutocompleteOption<TValue = string> {
|
||||
@@ -81,6 +81,9 @@ export interface AutocompleteProps<
|
||||
* Props passed to the input component.
|
||||
*/
|
||||
input?: Partial<Omit<InputProps, 'ref'>>;
|
||||
inputRoot?: Partial<
|
||||
DetailedHTMLProps<HTMLProps<HTMLInputElement>, HTMLInputElement>
|
||||
> & { 'data-testid'?: string };
|
||||
/**
|
||||
* Props passed to the input's `FormControl` component.
|
||||
*/
|
||||
@@ -471,7 +474,10 @@ function Autocomplete(
|
||||
}
|
||||
: null,
|
||||
},
|
||||
inputRoot: { 'aria-label': ariaLabel },
|
||||
inputRoot: {
|
||||
'aria-label': ariaLabel,
|
||||
...slotProps.inputRoot,
|
||||
},
|
||||
label: InputLabelProps,
|
||||
formControl: formControlSlotProps,
|
||||
}}
|
||||
|
||||
@@ -244,6 +244,7 @@ export function MultiSelectValue({
|
||||
variant="outline"
|
||||
data-selected-item
|
||||
className="group flex items-center gap-1"
|
||||
data-testid={items.get(value)}
|
||||
key={value}
|
||||
onClick={
|
||||
clickToRemove
|
||||
@@ -256,7 +257,10 @@ export function MultiSelectValue({
|
||||
>
|
||||
{items.get(value)}
|
||||
{clickToRemove && (
|
||||
<XIcon className="size-2 text-muted-foreground group-hover:text-destructive" />
|
||||
<XIcon
|
||||
className="size-2 text-muted-foreground group-hover:text-destructive"
|
||||
data-testid={`${items.get(value)}-remove`}
|
||||
/>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
22
dashboard/src/components/ui/v3/textarea.tsx
Normal file
22
dashboard/src/components/ui/v3/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
mockPointerEvent,
|
||||
render,
|
||||
screen,
|
||||
TestUserEvent,
|
||||
} from '@/tests/testUtils';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { vi } from 'vitest';
|
||||
import type * as Yup from 'yup';
|
||||
import BaseTableForm, {
|
||||
type BaseTableFormValues,
|
||||
baseTableValidationSchema,
|
||||
} from './BaseTableForm';
|
||||
|
||||
mockPointerEvent();
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
value: vi.fn(() => ({
|
||||
width: 100,
|
||||
height: 40,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 40,
|
||||
right: 100,
|
||||
})),
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onSubmit: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultFormValues = {
|
||||
columns: [
|
||||
{
|
||||
name: '',
|
||||
type: null as any,
|
||||
defaultValue: null as any,
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
isIdentity: false,
|
||||
comment: '',
|
||||
},
|
||||
],
|
||||
foreignKeyRelations: [],
|
||||
primaryKeyIndices: [],
|
||||
identityColumnIndex: null,
|
||||
};
|
||||
|
||||
function TestTableFormWrapper({ defaultValues = defaultFormValues }: any) {
|
||||
const form = useForm<
|
||||
BaseTableFormValues | Yup.InferType<typeof baseTableValidationSchema>
|
||||
>({
|
||||
defaultValues,
|
||||
shouldUnregister: false,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseTableValidationSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BaseTableForm onSubmit={mocks.onSubmit} submitButtonText="Save" />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const user = new TestUserEvent();
|
||||
|
||||
async function fillColumnForm(
|
||||
{ columnName, optionName, typeValue, defaultValue }: any,
|
||||
index: number,
|
||||
) {
|
||||
const columnNameInput = screen.getByTestId(`columns.${index}.name`);
|
||||
expect(columnNameInput).toBeInTheDocument();
|
||||
await user.type(columnNameInput, columnName);
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByTestId(`columns.${index}.type`),
|
||||
);
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: optionName }),
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue(typeValue)).toBeInTheDocument();
|
||||
|
||||
if (defaultValue) {
|
||||
expect(
|
||||
screen.getByTestId(`columns.${index}.defaultValue`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
TestUserEvent.fireTypeEvent(
|
||||
screen.getByTestId(`columns.${index}.defaultValue`),
|
||||
`${defaultValue}`,
|
||||
);
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', {
|
||||
name: `Use "${defaultValue}" as a literal`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(`columns.${index}.defaultValue`)).toHaveValue(
|
||||
defaultValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseTableForm', () => {
|
||||
it('should not disable the nullable and unique checkboxes after setting the column name', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
|
||||
let firstColumnIsNullableCheckbox = screen.getByRole('checkbox', {
|
||||
name: (accessibleName, element) =>
|
||||
element.getAttribute('name') === 'columns.0.isNullable',
|
||||
});
|
||||
|
||||
let firstColumnIsUniqueCheckbox = screen.getByRole('checkbox', {
|
||||
name: (accessibleName, element) =>
|
||||
element.getAttribute('name') === 'columns.0.isUnique',
|
||||
});
|
||||
|
||||
expect(firstColumnIsNullableCheckbox).not.toBeDisabled();
|
||||
expect(firstColumnIsUniqueCheckbox).not.toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter name'), 'column1');
|
||||
expect(screen.getByDisplayValue('column1')).toBeInTheDocument();
|
||||
|
||||
firstColumnIsNullableCheckbox = screen.getByRole('checkbox', {
|
||||
name: (accessibleName, element) =>
|
||||
element.getAttribute('name') === 'columns.0.isNullable',
|
||||
});
|
||||
firstColumnIsUniqueCheckbox = screen.getByRole('checkbox', {
|
||||
name: (accessibleName, element) =>
|
||||
element.getAttribute('name') === 'columns.0.isUnique',
|
||||
});
|
||||
|
||||
expect(firstColumnIsNullableCheckbox).not.toBeDisabled();
|
||||
expect(firstColumnIsUniqueCheckbox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable the nullable and unique checkboxes if the column is the primary key', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'id',
|
||||
optionName: 'uuid uuid',
|
||||
typeValue: 'uuid',
|
||||
defaultValue: 'gen_random_uuid()',
|
||||
},
|
||||
0,
|
||||
);
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'note',
|
||||
optionName: 'text text',
|
||||
typeValue: 'text',
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'isDone',
|
||||
optionName: 'boolean bool',
|
||||
typeValue: 'boolean',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByText('Add Primary Key'));
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'id' }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('id')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: (accessibleName, element) =>
|
||||
element.getAttribute('name') === 'columns.0.isNullable',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: (accessibleName, element) =>
|
||||
element.getAttribute('name') === 'columns.0.isUnique',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable the nullable and unique checkboxes and default value if the column is an identity column', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'id',
|
||||
optionName: 'uuid uuid',
|
||||
typeValue: 'uuid',
|
||||
defaultValue: 'gen_random_uuid()',
|
||||
},
|
||||
0,
|
||||
);
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'identity_column',
|
||||
optionName: 'smallint int2',
|
||||
typeValue: 'smallint',
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
TestUserEvent.fireClickEvent(screen.getByLabelText('Identity'));
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'identity_column' }),
|
||||
).toBeInTheDocument();
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'identity_column' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Identity' }).textContent).toBe(
|
||||
'identity_column',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the identity column picker if an integer is selected as a column type', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
|
||||
expect(screen.queryByLabelText('Identity')).not.toBeInTheDocument();
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByPlaceholderText('Select type'),
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Select type'), 'int');
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'smallint int2' }),
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('smallint')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Identity')).toBeInTheDocument();
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByDisplayValue('smallint'));
|
||||
|
||||
await user.type(screen.getByDisplayValue('smallint'), 'text');
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'text text' }),
|
||||
);
|
||||
expect(screen.getByDisplayValue('text')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Identity')).not.toBeInTheDocument();
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByDisplayValue('text'));
|
||||
|
||||
await user.type(screen.getByDisplayValue('text'), 'int');
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'integer int4' }),
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('integer')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Identity')).toBeInTheDocument();
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByDisplayValue('integer'));
|
||||
|
||||
await user.type(screen.getByDisplayValue('integer'), 'numeric');
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'numeric numeric' }),
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('numeric')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Identity')).not.toBeInTheDocument();
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByDisplayValue('numeric'));
|
||||
await user.type(screen.getByDisplayValue('numeric'), 'int');
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'bigint int8' }),
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('bigint')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Identity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add a new empty row with the Add Column button', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('Enter name')).toHaveLength(2);
|
||||
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
expect(screen.getAllByPlaceholderText('Enter name')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('the remove column button is disabled if it is the only column', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
expect(screen.getByTestId('remove-column-0')).toBeDisabled();
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('remove-column-0')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('remove-column-1')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('remove-column-2')).not.toBeDisabled();
|
||||
|
||||
TestUserEvent.fireClickEvent(screen.getByTestId('remove-column-1'));
|
||||
expect(screen.getByTestId('remove-column-0')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('remove-column-1')).not.toBeDisabled();
|
||||
|
||||
TestUserEvent.fireClickEvent(screen.getByTestId('remove-column-1'));
|
||||
|
||||
expect(screen.getByTestId('remove-column-0')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should add a comment to the column', async () => {
|
||||
render(<TestTableFormWrapper />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByTestId('tableNameInput'), 'test_table');
|
||||
|
||||
expect(screen.getByTestId('tableNameInput')).toHaveDisplayValue(
|
||||
'test_table',
|
||||
);
|
||||
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'id',
|
||||
optionName: 'uuid uuid',
|
||||
typeValue: 'uuid',
|
||||
defaultValue: 'gen_random_uuid()',
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByTestId('columns.0.comment'));
|
||||
expect(
|
||||
screen.getByPlaceholderText('Add a comment for the column'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Add a comment for the column'),
|
||||
'Test comment{Escape}',
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Add a comment for the column'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'description',
|
||||
optionName: 'text text',
|
||||
typeValue: 'text',
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('button', { name: /Add Column/ }),
|
||||
);
|
||||
await fillColumnForm(
|
||||
{
|
||||
columnName: 'identity_column',
|
||||
optionName: 'smallint int2',
|
||||
typeValue: 'smallint',
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByText('Add Primary Key'));
|
||||
|
||||
await TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'id' }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('id')).toBeInTheDocument();
|
||||
|
||||
TestUserEvent.fireClickEvent(screen.getByLabelText('Identity'));
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'identity_column' }),
|
||||
).toBeInTheDocument();
|
||||
TestUserEvent.fireClickEvent(
|
||||
screen.getByRole('option', { name: 'identity_column' }),
|
||||
);
|
||||
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
|
||||
await TestUserEvent.fireClickEvent(screen.getByText('Save'));
|
||||
|
||||
expect(screen.getByText('Save')).not.toBeDisabled();
|
||||
|
||||
expect(mocks.onSubmit.mock.calls[0][0].name).toBe('test_table');
|
||||
expect(mocks.onSubmit.mock.calls[0][0].primaryKeyIndices).toStrictEqual([
|
||||
'0',
|
||||
]);
|
||||
expect(mocks.onSubmit.mock.calls[0][0].identityColumnIndex).toBe(2);
|
||||
expect(mocks.onSubmit.mock.calls[0][0].columns).toStrictEqual([
|
||||
{
|
||||
name: 'id',
|
||||
type: { group: 'UUID types', label: 'uuid', value: 'uuid' },
|
||||
defaultValue: {
|
||||
custom: true,
|
||||
dropdownLabel: 'Use "gen_random_uuid()" as a literal',
|
||||
label: 'gen_random_uuid()',
|
||||
value: 'gen_random_uuid()',
|
||||
},
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
isIdentity: false,
|
||||
comment: 'Test comment',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: { group: 'String types', label: 'text', value: 'text' },
|
||||
defaultValue: null,
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
isIdentity: false,
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
comment: null,
|
||||
defaultValue: null,
|
||||
isIdentity: false,
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
name: 'identity_column',
|
||||
type: {
|
||||
group: 'Numeric types',
|
||||
label: 'smallint',
|
||||
value: 'int2',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,9 @@ function NameInput() {
|
||||
{...register('name')}
|
||||
id="name"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
'data-testid': 'tableNameInput',
|
||||
}}
|
||||
label="Name"
|
||||
helperText={
|
||||
typeof errors.name?.message === 'string' ? errors.name?.message : ''
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { Textarea } from '@/components/ui/v3/textarea';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { MessageSquare, MessageSquareText } from 'lucide-react';
|
||||
import { type KeyboardEvent } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
interface ColumnCommentProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
function ColumnComment({ index }: ColumnCommentProps) {
|
||||
const { register } = useFormContext();
|
||||
const comment = useWatch({ name: `columns.${index}.comment` });
|
||||
|
||||
const CommentIcon = isEmptyValue(comment) ? MessageSquare : MessageSquareText;
|
||||
const title = isEmptyValue(comment) ? 'Add comment' : 'Edit comment';
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={title}
|
||||
data-testid={`columns.${index}.comment`}
|
||||
className="h-8 w-8 hover:bg-[#eaedf0] dark:hover:bg-[#2f363d]"
|
||||
>
|
||||
<CommentIcon strokeWidth={1} className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0 data-[state=closed]:duration-100 data-[state=open]:duration-100"
|
||||
align="end"
|
||||
>
|
||||
<Textarea
|
||||
{...register(`columns.${index}.comment`, { shouldUnregister: false })}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="resize-none"
|
||||
placeholder="Add a comment for the column"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnComment;
|
||||
@@ -7,6 +7,7 @@ import { InlineCode } from '@/components/presentational/InlineCode';
|
||||
import type { CheckboxProps } from '@/components/ui/v2/Checkbox';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { OptionBase } from '@/components/ui/v2/Option';
|
||||
|
||||
import type {
|
||||
ColumnType,
|
||||
ForeignKeyRelation,
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
postgresTypeGroups,
|
||||
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { PropsWithoutRef } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
import { useFormContext, useFormState, useWatch } from 'react-hook-form';
|
||||
import ColumnComment from './ColumnComment';
|
||||
import { RemoveButton } from './RemoveButton';
|
||||
|
||||
export interface FieldArrayInputProps {
|
||||
@@ -43,7 +46,7 @@ function NameInput({ index }: FieldArrayInputProps) {
|
||||
name: [`columns.${index}.name`],
|
||||
});
|
||||
|
||||
const primaryKeyIndex: number = useWatch({ name: 'primaryKeyIndex' });
|
||||
const primaryKeyIndices: string[] = useWatch({ name: 'primaryKeyIndices' });
|
||||
|
||||
return (
|
||||
<Input
|
||||
@@ -61,17 +64,12 @@ function NameInput({ index }: FieldArrayInputProps) {
|
||||
clearErrors('columns');
|
||||
}
|
||||
|
||||
if (!event.target.value && primaryKeyIndex === index) {
|
||||
setValue('primaryKeyIndex', null);
|
||||
if (!event.target.value && primaryKeyIndices.includes(`${index}`)) {
|
||||
const updatedPrimaryKeyIndices = primaryKeyIndices.filter(
|
||||
(pk) => pk !== `${index}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target.value &&
|
||||
(primaryKeyIndex === null || typeof primaryKeyIndex === 'undefined')
|
||||
) {
|
||||
setValue('primaryKeyIndex', index);
|
||||
setValue('primaryKeyIndices', updatedPrimaryKeyIndices);
|
||||
}
|
||||
},
|
||||
})}
|
||||
@@ -81,6 +79,7 @@ function NameInput({ index }: FieldArrayInputProps) {
|
||||
hideEmptyHelperText
|
||||
error={Boolean(errors?.columns?.[index]?.name)}
|
||||
helperText={errors?.columns?.[index]?.name?.message}
|
||||
inputProps={{ 'data-testid': `columns.${index}.name` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +97,11 @@ function TypeAutocomplete({ index }: FieldArrayInputProps) {
|
||||
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
'data-testid': `columns.${index}.type`,
|
||||
},
|
||||
}}
|
||||
id={`columns.${index}.type`}
|
||||
name={`columns.${index}.type`}
|
||||
aria-label="Type"
|
||||
@@ -201,6 +205,9 @@ function DefaultValueAutocomplete({ index }: FieldArrayInputProps) {
|
||||
freeSolo
|
||||
slotProps={{
|
||||
paper: { className: clsx(availableFunctions.length === 0 && 'hidden') },
|
||||
inputRoot: {
|
||||
'data-testid': `columns.${index}.defaultValue`,
|
||||
},
|
||||
}}
|
||||
disabled={isIdentity}
|
||||
noOptionsText="Enter a custom default value"
|
||||
@@ -225,10 +232,10 @@ function Checkbox({
|
||||
index,
|
||||
...props
|
||||
}: FieldArrayInputProps & PropsWithoutRef<CheckboxProps>) {
|
||||
const primaryKeyIndex = useWatch({ name: 'primaryKeyIndex' });
|
||||
const primaryKeyIndices = useWatch({ name: 'primaryKeyIndices' });
|
||||
const identityColumnIndex = useWatch({ name: 'identityColumnIndex' });
|
||||
|
||||
const isPrimary = primaryKeyIndex === index;
|
||||
const isPrimary = primaryKeyIndices.includes(`${index}`);
|
||||
const isIdentity = identityColumnIndex === index;
|
||||
|
||||
return (
|
||||
@@ -249,28 +256,39 @@ export interface ColumnEditorRowProps extends FieldArrayInputProps {
|
||||
}
|
||||
|
||||
const ColumnEditorRow = memo(({ index, remove }: ColumnEditorRowProps) => (
|
||||
<div role="row" className="grid w-full grid-cols-12 gap-1">
|
||||
<div role="cell" className="col-span-3">
|
||||
<div role="row" className="flex w-full gap-2">
|
||||
<div role="cell" className="w-52 flex-none">
|
||||
<NameInput index={index} />
|
||||
</div>
|
||||
|
||||
<div role="cell" className="col-span-3">
|
||||
<div role="cell" className="w-52 flex-none">
|
||||
<TypeAutocomplete index={index} />
|
||||
</div>
|
||||
|
||||
<div role="cell" className="col-span-3">
|
||||
<div role="cell" className="w-52 flex-none">
|
||||
<DefaultValueAutocomplete index={index} />
|
||||
</div>
|
||||
|
||||
<div role="cell" className="col-span-1 flex justify-center py-3">
|
||||
<div role="cell" className="flex w-8 flex-none items-center justify-center">
|
||||
<ColumnComment index={index} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="cell"
|
||||
className="flex w-13 flex-none items-center justify-center"
|
||||
>
|
||||
<Checkbox
|
||||
name={`columns.${index}.isNullable`}
|
||||
aria-label="Nullable"
|
||||
index={index}
|
||||
data-testid={`columns.${index}.isNullable`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div role="cell" className="col-span-1 flex justify-center py-3">
|
||||
<div
|
||||
role="cell"
|
||||
className="flex w-13 flex-none items-center justify-center"
|
||||
>
|
||||
<Checkbox
|
||||
name={`columns.${index}.isUnique`}
|
||||
aria-label="Unique"
|
||||
@@ -278,7 +296,7 @@ const ColumnEditorRow = memo(({ index, remove }: ColumnEditorRowProps) => (
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div role="cell" className="col-span-1 flex justify-center py-0.5">
|
||||
<div role="cell" className="flex w-9 flex-none items-center justify-center">
|
||||
<RemoveButton
|
||||
index={index}
|
||||
onClick={() => {
|
||||
|
||||
@@ -25,9 +25,9 @@ export default function ColumnEditorTable() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div role="table" className="col-span-8">
|
||||
<div className="sticky top-0 z-10 grid w-full grid-cols-12 gap-1 pb-2 pt-1">
|
||||
<div role="columnheader" className="col-span-3">
|
||||
<div role="table" className="col-span-8 overflow-x-auto">
|
||||
<div className="sticky top-0 z-10 flex w-full gap-2 pb-2 pt-1">
|
||||
<div role="columnheader" className="w-52 flex-none">
|
||||
<InputLabel as="span">
|
||||
Name
|
||||
<Text component="span" color="error">
|
||||
@@ -36,7 +36,7 @@ export default function ColumnEditorTable() {
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div role="columnheader" className="col-span-3">
|
||||
<div role="columnheader" className="w-52 flex-none">
|
||||
<InputLabel as="span">
|
||||
Type
|
||||
<Text component="span" color="error">
|
||||
@@ -45,24 +45,30 @@ export default function ColumnEditorTable() {
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div role="columnheader" className="col-span-3">
|
||||
<div role="columnheader" className="w-52 flex-none">
|
||||
<InputLabel as="span">Default Value</InputLabel>
|
||||
</div>
|
||||
<div role="columnheader" className="w-8 flex-none">
|
||||
<InputLabel as="span" className="hidden">
|
||||
Comment
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div role="columnheader" className="col-span-1 truncate text-center">
|
||||
<div role="columnheader" className="w-13 flex-none text-center">
|
||||
<InputLabel as="span" className="truncate">
|
||||
Nullable
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div role="columnheader" className="col-span-1 truncate text-center">
|
||||
<div role="columnheader" className="w-13 flex-none text-center">
|
||||
<InputLabel as="span" className="truncate">
|
||||
Unique
|
||||
</InputLabel>
|
||||
</div>
|
||||
<div className="flex w-9 flex-auto" />
|
||||
</div>
|
||||
|
||||
<div role="rowgroup" className="grid w-full grid-flow-row gap-2">
|
||||
<div role="rowgroup" className="grid w-full grid-flow-row gap-1">
|
||||
{fields.map((field, index) => (
|
||||
<ColumnEditorRow key={field.id} index={index} remove={remove} />
|
||||
))}
|
||||
@@ -82,6 +88,7 @@ export default function ColumnEditorTable() {
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
isIdentity: false,
|
||||
comment: null,
|
||||
})
|
||||
}
|
||||
startIcon={<PlusIcon />}
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function ForeignKeyEditorSection() {
|
||||
validateDuplicateRelation(values);
|
||||
append(values);
|
||||
}
|
||||
const primaryKeyIndices = getValues('primaryKeyIndices');
|
||||
|
||||
return (
|
||||
<section className="grid grid-flow-row gap-2 px-6">
|
||||
@@ -71,15 +72,13 @@ export default function ForeignKeyEditorSection() {
|
||||
<ForeignKeyEditorRow
|
||||
index={index}
|
||||
onEdit={() => {
|
||||
const primaryKeyIndex = getValues('primaryKeyIndex');
|
||||
|
||||
openDialog({
|
||||
title: 'Edit Foreign Key Relation',
|
||||
component: (
|
||||
<EditForeignKeyForm
|
||||
foreignKeyRelation={fields[index] as ForeignKeyRelation}
|
||||
availableColumns={columns.map((column, columnIndex) =>
|
||||
columnIndex === primaryKeyIndex
|
||||
primaryKeyIndices.includes(`${columnIndex}`)
|
||||
? { ...column, isPrimary: true }
|
||||
: column,
|
||||
)}
|
||||
@@ -109,8 +108,6 @@ export default function ForeignKeyEditorSection() {
|
||||
}}
|
||||
disabled={columnsWithNameAndType?.length === 0}
|
||||
onClick={() => {
|
||||
const primaryKeyIndex = getValues('primaryKeyIndex');
|
||||
|
||||
openDialog({
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
@@ -125,7 +122,7 @@ export default function ForeignKeyEditorSection() {
|
||||
component: (
|
||||
<CreateForeignKeyForm
|
||||
availableColumns={columns.map((column, index) =>
|
||||
index === primaryKeyIndex
|
||||
primaryKeyIndices.includes(`${index}`)
|
||||
? { ...column, isPrimary: true }
|
||||
: column,
|
||||
)}
|
||||
|
||||
@@ -75,5 +75,3 @@ export default function PrimaryKeySelect() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// #1e293b
|
||||
// #1b2534
|
||||
|
||||
@@ -67,13 +67,14 @@ export default function CreateTableForm({
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
isIdentity: false,
|
||||
comment: '',
|
||||
},
|
||||
],
|
||||
foreignKeyRelations: [],
|
||||
primaryKeyIndices: [],
|
||||
identityColumnIndex: null,
|
||||
},
|
||||
shouldUnregister: true,
|
||||
shouldUnregister: false,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseTableValidationSchema),
|
||||
});
|
||||
@@ -86,6 +87,7 @@ export default function CreateTableForm({
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
try {
|
||||
const table: DatabaseTable = {
|
||||
...values,
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function DatabaseRecordInputGroup({
|
||||
const isMultiline =
|
||||
specificType === 'text' ||
|
||||
specificType === 'bpchar' ||
|
||||
specificType === 'character varying' ||
|
||||
specificType === 'varchar' ||
|
||||
specificType === 'json' ||
|
||||
specificType === 'jsonb';
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function EditTableForm({
|
||||
defaultValue: column.defaultValue,
|
||||
isNullable: column.isNullable,
|
||||
isUnique: column.isUnique,
|
||||
comment: column.comment || '',
|
||||
})),
|
||||
primaryKeyIndices,
|
||||
identityColumnIndex:
|
||||
@@ -153,7 +154,7 @@ export default function EditTableForm({
|
||||
|
||||
async function handleSubmit(values: BaseTableFormValues) {
|
||||
const primaryKey = values.primaryKeyIndices.map<string>(
|
||||
(primaryKeys, primaryKeyIndex) => values.columns[primaryKeyIndex].name,
|
||||
(primaryKeys) => values.columns[primaryKeys].name,
|
||||
);
|
||||
try {
|
||||
const updatedTable: DatabaseTable = {
|
||||
|
||||
@@ -67,7 +67,6 @@ describe('prepareCreateTableQuery', () => {
|
||||
schema: 'public',
|
||||
table,
|
||||
});
|
||||
|
||||
expect(transaction).toHaveLength(1);
|
||||
expect(transaction[0].args.sql).toBe(
|
||||
'CREATE TABLE public.test_table (id uuid NOT NULL, name text NOT NULL, author_id uuid NOT NULL, PRIMARY KEY (id), FOREIGN KEY (author_id) REFERENCES public.authors (id) ON UPDATE RESTRICT ON DELETE RESTRICT);',
|
||||
@@ -244,4 +243,37 @@ describe('prepareCreateTableQuery', () => {
|
||||
'CREATE TABLE public.test_table (id uuid NOT NULL, name varchar(10) NOT NULL);',
|
||||
);
|
||||
});
|
||||
|
||||
test('should add comments to columns', () => {
|
||||
const table: DatabaseTable = {
|
||||
name: 'test_table',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: { value: 'uuid', label: 'UUID' },
|
||||
comment: 'Primary key comment',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: { value: 'text', label: 'Text' },
|
||||
comment: 'Text comment',
|
||||
},
|
||||
],
|
||||
primaryKey: ['id'],
|
||||
};
|
||||
|
||||
const transaction = prepareCreateTableQuery({
|
||||
dataSource: 'default',
|
||||
schema: 'public',
|
||||
table,
|
||||
});
|
||||
|
||||
expect(transaction).toHaveLength(3);
|
||||
expect(transaction[1].args.sql).toBe(
|
||||
"COMMENT ON COLUMN public.test_table.id is 'Primary key comment';",
|
||||
);
|
||||
expect(transaction[2].args.sql).toBe(
|
||||
"COMMENT ON COLUMN public.test_table.name is 'Text comment';",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@ import type {
|
||||
DatabaseTable,
|
||||
MutationOrQueryBaseOptions,
|
||||
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import { getPreparedHasuraQuery } from '@/features/orgs/projects/database/dataGrid/utils/hasuraQueryHelpers';
|
||||
import {
|
||||
getPreparedHasuraQuery,
|
||||
type HasuraOperation,
|
||||
} from '@/features/orgs/projects/database/dataGrid/utils/hasuraQueryHelpers';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { format } from 'node-pg-format';
|
||||
|
||||
@@ -80,6 +83,23 @@ export default function prepareCreateTableQuery({
|
||||
);
|
||||
}
|
||||
|
||||
const hasColumnComments = table.columns.some(({ comment }) =>
|
||||
isNotEmptyValue(comment),
|
||||
);
|
||||
let columnComments: HasuraOperation[] = [];
|
||||
if (hasColumnComments) {
|
||||
columnComments = table.columns.map(({ comment, name }) =>
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'COMMENT ON COLUMN %I.%I.%I is %L',
|
||||
schema,
|
||||
table.name,
|
||||
name,
|
||||
comment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
@@ -88,5 +108,6 @@ export default function prepareCreateTableQuery({
|
||||
table.name,
|
||||
columnsAndConstraints,
|
||||
),
|
||||
...columnComments,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -515,4 +515,71 @@ describe('prepareUpdateTableQuery', () => {
|
||||
expect(dropConstraintQuery).toBeDefined();
|
||||
expect(addPrimaryKeyQuery).toBeUndefined();
|
||||
});
|
||||
test('should prepare a query for adding comment to with the old table name', () => {
|
||||
const updatedTable: DatabaseTable = {
|
||||
name: 'test_table_renamed',
|
||||
primaryKey: ['id'],
|
||||
columns: [
|
||||
{
|
||||
id: 'id',
|
||||
name: 'id',
|
||||
type: { value: 'uuid', label: 'UUID' },
|
||||
defaultValue: {
|
||||
value: 'gen_random_uuid()',
|
||||
label: 'gen_random_uuid()',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'author_id',
|
||||
name: 'author_id',
|
||||
type: { value: 'int4', label: 'int4' },
|
||||
comment: 'Author id',
|
||||
},
|
||||
],
|
||||
foreignKeyRelations: [],
|
||||
};
|
||||
|
||||
const transaction = prepareUpdateTableQuery({
|
||||
dataSource: 'default',
|
||||
schema: 'public',
|
||||
originalTable,
|
||||
updatedTable,
|
||||
originalColumns,
|
||||
originalForeignKeyRelations: [],
|
||||
});
|
||||
|
||||
expect(transaction).toHaveLength(2);
|
||||
expect(transaction[0].args.sql).toBe(
|
||||
"COMMENT ON COLUMN public.test_table.author_id IS 'Author id';",
|
||||
);
|
||||
});
|
||||
|
||||
test('should prepare a query for adding comment to the table', () => {
|
||||
const updatedTable: DatabaseTable = {
|
||||
name: 'test_table',
|
||||
primaryKey: ['id'],
|
||||
columns: originalColumns.map((c, index) => ({
|
||||
...c,
|
||||
comment: `comment ${index}`,
|
||||
})),
|
||||
foreignKeyRelations: [],
|
||||
};
|
||||
|
||||
const transaction = prepareUpdateTableQuery({
|
||||
dataSource: 'default',
|
||||
schema: 'public',
|
||||
originalTable,
|
||||
updatedTable,
|
||||
originalColumns,
|
||||
originalForeignKeyRelations: [],
|
||||
});
|
||||
|
||||
expect(transaction).toHaveLength(2);
|
||||
expect(transaction[0].args.sql).toBe(
|
||||
"COMMENT ON COLUMN public.test_table.id IS 'comment 0';",
|
||||
);
|
||||
expect(transaction[1].args.sql).toBe(
|
||||
"COMMENT ON COLUMN public.test_table.author_id IS 'comment 1';",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,7 +220,7 @@ export interface ColumnInsertOptions {
|
||||
/**
|
||||
* User defined column type of a character field in PostgreSQL.
|
||||
*/
|
||||
export type CharacterColumnType = 'character varying' | 'bpchar' | 'text';
|
||||
export type CharacterColumnType = 'varchar' | 'bpchar' | 'text';
|
||||
|
||||
/**
|
||||
* User defined column type of a boolean field in PostgreSQL.
|
||||
@@ -249,7 +249,6 @@ export type DateColumnType =
|
||||
export type NumericColumnType =
|
||||
| 'oid'
|
||||
| 'numeric'
|
||||
| 'int'
|
||||
| 'int2'
|
||||
| 'int4'
|
||||
| 'serial'
|
||||
|
||||
@@ -7,9 +7,6 @@ describe('getInputType', () => {
|
||||
expect(getInputType({ type: 'number', specificType: 'numeric' })).toBe(
|
||||
'number',
|
||||
);
|
||||
expect(getInputType({ type: 'number', specificType: 'int' })).toBe(
|
||||
'number',
|
||||
);
|
||||
expect(getInputType({ type: 'number', specificType: 'int4' })).toBe(
|
||||
'number',
|
||||
);
|
||||
@@ -17,9 +14,9 @@ describe('getInputType', () => {
|
||||
|
||||
test('should return "text" if the column is text based', () => {
|
||||
expect(getInputType({ type: 'text', specificType: 'text' })).toBe('text');
|
||||
expect(
|
||||
getInputType({ type: 'text', specificType: 'character varying' }),
|
||||
).toBe('text');
|
||||
expect(getInputType({ type: 'text', specificType: 'varchar' })).toBe(
|
||||
'text',
|
||||
);
|
||||
expect(getInputType({ type: 'text', specificType: 'bpchar' })).toBe('text');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { NormalizedQueryDataRow } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import { postgresTypeGroups } from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||
|
||||
function getColumnValue(column: NormalizedQueryDataRow) {
|
||||
if (
|
||||
column.data_type === 'USER-DEFINED' ||
|
||||
column.full_data_type.indexOf('(') > -1
|
||||
) {
|
||||
return column.full_data_type;
|
||||
}
|
||||
return column.udt_name;
|
||||
}
|
||||
|
||||
function normalizeColumnType(column: NormalizedQueryDataRow) {
|
||||
const label =
|
||||
postgresTypeGroups.find((pt) => pt.value === column.full_data_type)
|
||||
?.label || column.full_data_type;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: column.full_data_type,
|
||||
value: getColumnValue(column),
|
||||
custom:
|
||||
column.data_type === 'USER-DEFINED' ||
|
||||
column.full_data_type !== column.udt_name,
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function normalizeDatabaseColumn(
|
||||
isIdentity: rawColumn.is_identity === 'YES',
|
||||
isNullable: rawColumn.is_nullable === 'YES',
|
||||
isUnique: rawColumn.is_unique,
|
||||
comment: rawColumn.comment || null,
|
||||
comment: rawColumn.column_comment || null,
|
||||
defaultValue: rawColumn.column_default
|
||||
? {
|
||||
value: normalizedDefaultValue,
|
||||
|
||||
@@ -36,11 +36,7 @@ export const POSTGRESQL_DECIMAL_TYPES = ['numeric', 'real', 'double precision'];
|
||||
*
|
||||
* @docs https://www.postgresql.org/docs/current/datatype-character.html
|
||||
*/
|
||||
export const POSTGRESQL_CHARACTER_TYPES = [
|
||||
'character varying',
|
||||
'character',
|
||||
'text',
|
||||
];
|
||||
export const POSTGRESQL_CHARACTER_TYPES = ['varchar', 'character', 'text'];
|
||||
|
||||
/**
|
||||
* JSON data types in PostgreSQL.
|
||||
@@ -75,7 +71,7 @@ export const postgresTypeGroups: {
|
||||
{
|
||||
group: 'String types',
|
||||
label: 'character varying',
|
||||
value: 'character varying',
|
||||
value: 'varchar',
|
||||
},
|
||||
{ group: 'String types', label: 'character', value: 'bpchar' },
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function DataGridTextCell<TData extends object>({
|
||||
const isMultiline =
|
||||
specificType === 'text' ||
|
||||
specificType === 'bpchar' ||
|
||||
specificType === 'character varying' ||
|
||||
specificType === 'varchar' ||
|
||||
specificType === 'json' ||
|
||||
specificType === 'jsonb';
|
||||
|
||||
|
||||
@@ -219,6 +219,16 @@ export class TestUserEvent {
|
||||
});
|
||||
}
|
||||
|
||||
async keyboard(value: string) {
|
||||
await waitFor(async () => {
|
||||
await this.user.keyboard(value);
|
||||
});
|
||||
}
|
||||
|
||||
async keyboardWithoutWaitFor(value: string) {
|
||||
await this.user.keyboard(value);
|
||||
}
|
||||
|
||||
async clear(element: Element) {
|
||||
await waitFor(async () => {
|
||||
await this.user.clear(element);
|
||||
@@ -236,6 +246,17 @@ export class TestUserEvent {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async fireTypeEvent(element: Element, text: string) {
|
||||
await waitFor(() => {
|
||||
fireEvent.change(element, {
|
||||
target: { value: text },
|
||||
});
|
||||
fireEvent.input(element, {
|
||||
target: { value: text },
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
inherit self pkgs nix-filter nixops-lib;
|
||||
};
|
||||
|
||||
storagef = import ./services/storage/project.nix {
|
||||
inherit self pkgs nix-filter nixops-lib;
|
||||
};
|
||||
|
||||
tutorialsf = import ./examples/tutorials/project.nix {
|
||||
inherit self pkgs nix-filter nixops-lib nix2containerPkgs;
|
||||
};
|
||||
@@ -78,6 +82,7 @@
|
||||
mintlify-openapi = mintlify-openapif.check;
|
||||
nhost-js = nhost-jsf.check;
|
||||
nixops = nixopsf.check;
|
||||
storage = storagef.check;
|
||||
tutorials = tutorialsf.check;
|
||||
};
|
||||
|
||||
@@ -155,6 +160,7 @@
|
||||
mintlify-openapi = mintlify-openapif.devShell;
|
||||
nhost-js = nhost-jsf.devShell;
|
||||
nixops = nixopsf.devShell;
|
||||
storage = storagef.devShell;
|
||||
tutorials = tutorialsf.devShell;
|
||||
};
|
||||
|
||||
@@ -170,6 +176,9 @@
|
||||
mintlify-openapi = mintlify-openapif.package;
|
||||
nhost-js = nhost-jsf.package;
|
||||
nixops = nixopsf.package;
|
||||
storage = storagef.package;
|
||||
storage-docker-image = storagef.dockerImage;
|
||||
clamav-docker-image = storagef.clamav-docker-image;
|
||||
tutorials = tutorialsf.package;
|
||||
};
|
||||
}
|
||||
|
||||
52
go.mod
52
go.mod
@@ -5,26 +5,43 @@ go 1.24.2
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.80
|
||||
github.com/Yamashou/gqlgenc v0.33.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.15
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.68
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/davidbyttow/govips/v2 v2.16.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.8
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/gin-contrib/cors v1.7.3
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-git/go-git/v5 v5.16.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-getter v1.8.1
|
||||
github.com/nhost/be v0.0.0-20250826134155-48638c4b3f6a
|
||||
github.com/oapi-codegen/gin-middleware v1.0.2
|
||||
github.com/oapi-codegen/runtime v1.1.1
|
||||
github.com/pb33f/libopenapi v0.21.12
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
github.com/urfave/cli/v3 v3.3.3
|
||||
github.com/wI2L/jsondiff v0.7.0
|
||||
go.uber.org/mock v0.5.0
|
||||
golang.org/x/mod v0.28.0
|
||||
golang.org/x/term v0.35.0
|
||||
gopkg.in/evanphx/json-patch.v5 v5.9.11
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -45,10 +62,8 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.68 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||
@@ -58,7 +73,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 // indirect
|
||||
@@ -86,39 +100,46 @@ require (
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-safetemp v1.0.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -126,8 +147,12 @@ require (
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
@@ -140,11 +165,16 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/cors v1.11.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
@@ -156,6 +186,7 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.30 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
@@ -164,14 +195,15 @@ require (
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
|
||||
167
go.sum
167
go.sum
@@ -28,6 +28,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/99designs/gqlgen v0.17.80 h1:S64VF9SK+q3JjQbilgdrM0o4iFQgB54mVQ3QvXEO4Ek=
|
||||
github.com/99designs/gqlgen v0.17.80/go.mod h1:vgNcZlLwemsUhYim4dC1pvFP5FX0pr2Y+uYUoHFb1ig=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ=
|
||||
@@ -43,6 +45,7 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/Yamashou/gqlgenc v0.33.0 h1:0fxTnNE8/JVmFpfo7reA5pEgOcr7VjNc+/nEpVhNjfc=
|
||||
github.com/Yamashou/gqlgenc v0.33.0/go.mod h1:MZGXx/nALyxcehcFeLGmYiNsJ+hQTOGJzNYCGNX4rL0=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
@@ -53,6 +56,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
@@ -101,6 +106,7 @@ 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/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
@@ -127,6 +133,11 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
|
||||
github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
@@ -137,8 +148,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
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/davidbyttow/govips/v2 v2.16.0 h1:1nH/Rbx8qZP1hd+oYL9fYQjAnm1+KorX9s07ZGseQmo=
|
||||
github.com/davidbyttow/govips/v2 v2.16.0/go.mod h1:clH5/IDVmG5eVyc23qYpyi7kmOT0B/1QNTKtci4RkyM=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emicklei/proto v1.14.0 h1:WYxC0OrBuuC+FUCTZvb8+fzEHdZMwLEF+OnVfZA3LXU=
|
||||
@@ -155,8 +178,16 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
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/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.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
|
||||
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
@@ -178,6 +209,10 @@ 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/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -188,18 +223,25 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -213,20 +255,29 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
||||
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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 h1:81+kWbE1yErFBMjME0I5k3x3kojjKsWtPYHEAutoPow=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65/go.mod h1:WtMzv9T++tfWVea+qB2MXoaqxw33S8bpJslzUike2mQ=
|
||||
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-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-getter v1.8.1 h1:5Ew/2lABx4iHbGhNUuo3Vheqypxn+nCraVOZOrPLmwQ=
|
||||
github.com/hashicorp/go-getter v1.8.1/go.mod h1:2mndIb0CxmdA4Vdc9KcsaAQ/NpADl76u5VSfhRUpEC4=
|
||||
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/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo=
|
||||
github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -237,8 +288,11 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
@@ -270,17 +324,34 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
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/nhost/be v0.0.0-20250826134155-48638c4b3f6a h1:X0diMLIRQBKobQ4W5di8fPqwakLFchJdzAtamO97Vrs=
|
||||
github.com/nhost/be v0.0.0-20250826134155-48638c4b3f6a/go.mod h1:iRPhO+qcQzTtNQ7PaQMJAcEw0tgWzgjzcGWgJ4ifrUo=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oapi-codegen/gin-middleware v1.0.2 h1:/H99UzvHQAUxXK8pzdcGAZgjCVeXdFDAUUWaJT0k0eI=
|
||||
github.com/oapi-codegen/gin-middleware v1.0.2/go.mod h1:2HJDQjH8jzK2/k/VKcWl+/T41H7ai2bKa6dN3AA2GpA=
|
||||
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -291,6 +362,8 @@ github.com/pb33f/libopenapi v0.21.12 h1:ityKYYWjiirJlz+slNaVF2NGfVF4Zn32H6CQEcrZ
|
||||
github.com/pb33f/libopenapi v0.21.12/go.mod h1:utT5sD2/mnN7YK68FfZT5yEPbI1wwRBpSS4Hi0oOrBU=
|
||||
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/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -325,6 +398,8 @@ github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692 h1:lwzJgPw5Y6p
|
||||
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692/go.mod h1:742Ialb8SOs5yB2PqRDzFcyND3280PoaS5/wcKQUQKE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
@@ -334,22 +409,39 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
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/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
|
||||
github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
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.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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -382,12 +474,15 @@ github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd h1:dLuIF2kX9c+KknGJUdJi1Il1SDiTSK158/BB9kdgAew=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
@@ -398,56 +493,114 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
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/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
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.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
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/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
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.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
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-20190423024810-112230192c58/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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
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-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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ=
|
||||
google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8=
|
||||
google.golang.org/genproto v0.0.0-20250728155136-f173205681a0 h1:btBcgujH2+KIWEfz0s7Cdtt9R7hpwM4SAEXAdXf/ddw=
|
||||
@@ -462,6 +615,7 @@ google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7I
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
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-20200902074654-038fdea0a05b/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/evanphx/json-patch.v5 v5.9.11 h1:OMPeiLomOQwe8+Ku4nwXsdOmrRw2vGUpP3XgLj3ojNw=
|
||||
@@ -469,6 +623,7 @@ gopkg.in/evanphx/json-patch.v5 v5.9.11/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpi
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -15,8 +15,13 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build:dashboard": "turbo run build --filter=@nhost/dashboard",
|
||||
"build:nhost-js": "turbo run build --filter=@nhost/nhost-js",
|
||||
"audit-ci": "pnpm exec audit-ci --config ./audit-ci.jsonc",
|
||||
"workspace:list": "pnpm ls --depth=-1 -r"
|
||||
"workspace:list": "pnpm ls --depth=-1 -r",
|
||||
"clean:all": "pnpm clean && rm -rf ./{{packages,examples/**,templates/**}/*,docs,dashboard}/{.nhost,node_modules} node_modules",
|
||||
"clean": "rm -rf ./{{packages,examples/**}/*,docs,dashboard}/{dist,umd,.next,.turbo,coverage}",
|
||||
"clean:install": "pnpm clean:all && pnpm install",
|
||||
"dev:dashboard": "turbo run dev --filter=@nhost/dashboard"
|
||||
},
|
||||
"devDependencies": {
|
||||
"audit-ci": "^6.6.1",
|
||||
|
||||
55
services/storage/Makefile
Normal file
55
services/storage/Makefile
Normal file
@@ -0,0 +1,55 @@
|
||||
ROOT_DIR?=$(abspath ../..)
|
||||
include $(ROOT_DIR)/build/makefiles/general.makefile
|
||||
|
||||
DOCKER_DEV_ENV_PATH=build/dev/docker
|
||||
|
||||
|
||||
.PHONY: _dev-env-up
|
||||
_dev-env-up:
|
||||
docker compose -f ${DOCKER_DEV_ENV_PATH}/docker-compose.yaml up -d
|
||||
|
||||
|
||||
.PHONY: _dev-env-down
|
||||
_dev-env-down:
|
||||
docker compose -f ${DOCKER_DEV_ENV_PATH}/docker-compose.yaml down --volumes
|
||||
|
||||
|
||||
.PHONY: _dev-env-build
|
||||
_dev-env-build: build-docker-image
|
||||
docker compose -f ${DOCKER_DEV_ENV_PATH}/docker-compose.yaml build
|
||||
|
||||
|
||||
.PHONY: dev-env-up-short
|
||||
dev-env-up-short: ## Starts development environment without hasura-storage
|
||||
docker compose -f ${DOCKER_DEV_ENV_PATH}/docker-compose.yaml up -d postgres graphql-engine minio clamd
|
||||
|
||||
|
||||
.PHONY: build-docker-image-clamav-dev
|
||||
build-docker-image-clamav-dev: ## Build dev docker container for clamav
|
||||
nix build $(docker-build-options) --show-trace \
|
||||
.\#packages.$(ARCH)-linux.clamav-docker-image \
|
||||
--print-build-logs
|
||||
nix develop \#skopeo -c \
|
||||
skopeo copy --insecure-policy dir:./result docker-daemon:clamav:$(VERSION)
|
||||
|
||||
|
||||
.PHONY: build-docker-image-clamav
|
||||
build-docker-image-clamav: ## Build docker container for clamav
|
||||
@echo $(VERSION) > VERSION
|
||||
nix build $(docker-build-options) --show-trace \
|
||||
.\#packages.aarch64-linux.clamav-docker-image \
|
||||
--print-build-logs
|
||||
nix develop \#skopeo -c \
|
||||
skopeo copy --insecure-policy dir:./result docker-daemon:clamav:$(VERSION)-aarch64
|
||||
nix build $(docker-build-options) --show-trace \
|
||||
.\#packages.x86_64-linux.clamav-docker-image \
|
||||
--print-build-logs
|
||||
nix develop \#skopeo -c \
|
||||
skopeo copy --insecure-policy dir:./result docker-daemon:clamav:$(VERSION)-x86_64
|
||||
docker push nhost/clamav:$(VERSION)-aarch64
|
||||
docker push nhost/clamav:$(VERSION)-x86_64
|
||||
docker manifest create \
|
||||
nhost/clamav:$(VERSION) \
|
||||
--amend nhost/clamav:$(VERSION)-aarch64 \
|
||||
--amend nhost/clamav:$(VERSION)-x86_64
|
||||
docker manifest push nhost/clamav:$(VERSION)
|
||||
95
services/storage/README.md
Normal file
95
services/storage/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Storage
|
||||
|
||||
Storage is a service that adds a storage service on top of hasura and any s3-compatible storage service. The goal is to be able to leverage the cloud storage service while also leveraging hasura features like its graphql API, permissions, actions, presets, etc...
|
||||
|
||||
## Workflows
|
||||
|
||||
To understand what Storage does we can look at the two main workflows to upload and retrieve files.
|
||||
|
||||
### Uploading files
|
||||
|
||||
When a user wants to upload a file Storage will first check with hasura if the user is allowed to do so, if it the file will be uploaded to s3 and, on completion, file metadata will be stored in hasura.
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
autonumber
|
||||
User->>+Storage: upload file
|
||||
Storage->>+hasura: check permissions
|
||||
hasura->>-Storage: return if user can upload file
|
||||
Storage->>+s3: upload file
|
||||
s3->>-Storage: file information
|
||||
Storage->>+hasura: file metadata
|
||||
hasura->>-Storage: success
|
||||
Storage->>-User: file metadata
|
||||
```
|
||||
|
||||
### Retrieving files
|
||||
|
||||
Similarly, when retrieving files, Storage will first check with hasura if the user has permissions to retrieve the file and if the user is allowed, it will forward the file to the user:
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
autonumber
|
||||
User->>+Storage: request file
|
||||
Storage->>+hasura: check permissions
|
||||
hasura->>-Storage: return if user can access file
|
||||
Storage->>+s3: request file
|
||||
s3->>-Storage: file
|
||||
Storage->>-User: file
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
The main features of the service are:
|
||||
|
||||
- leverage hasura's permissions to allow users to upload/retrieve files
|
||||
- upload files to any s3-compatible service
|
||||
- download files from any s3-compatible service
|
||||
- create presigned URLs to grant temporary access
|
||||
- caching information to integrate with caches and CDNs (cache headers, etag, conditional headers, etc)
|
||||
- perform basic image manipulation on the fly
|
||||
- integration with [clamav](https://www.clamav.net) antivirus
|
||||
|
||||
## Antivirus
|
||||
|
||||
Integration with [clamav](https://www.clamav.net) antivirus relies on an external [clamd](https://docs.clamav.net/manual/Usage/Scanning.html#clamd) service. When a file is uploaded `Storage` will create the file metadata first and then check if the file is clean with `clamd` via its TCP socket. If the file is clean the rest of the process will continue as usual. If a virus is found details about the virus will be added to the `virus` table and the rest of the process will be aborted.
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
User ->> storage: upload file
|
||||
storage ->>clamav: check for virus
|
||||
alt virus found
|
||||
storage-->s3: abort upload
|
||||
storage->>graphql: insert row in virus table
|
||||
else virus not found
|
||||
storage->>s3: upload
|
||||
storage->>graphql: update metadata
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
This feature can be enabled with the flag `--clamav-server string`, where `string` is the tcp address for the clamd service.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
The service comes with an [OpenAPI definition](/controller/openapi.yaml) which you can also see [online](https://editor.swagger.io/?url=https://raw.githubusercontent.com/nhost/Storage/main/controller/openapi.yaml).
|
||||
|
||||
## Using the service
|
||||
|
||||
Easiest way to get started is by using [nhost](https://nhost.io)'s free tier but if you want to self-host you can easily do it yourself as well.
|
||||
|
||||
### Self-hosting the service
|
||||
|
||||
Requirements:
|
||||
|
||||
1. [hasura](https://hasura.io) running, which in turns needs [postgres or any other supported database](https://hasura.io/docs/latest/graphql/core/databases/index/#supported-databases).
|
||||
2. An s3-compatible service. For instance, [AWS S3](https://aws.amazon.com/s3/), [minio](https://min.io), etc...
|
||||
|
||||
A fully working example using docker-compose can be found [here](/build/dev/docker/). Just remember to replace the image `Storage:dev` with a valid [docker image](https://hub.docker.com/r/nhost/storage/tags), for instance, `nhost/storage:0.1.5`.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you need help or want to contribute it is recommended to read the [contributing](/CONTRIBUTING.md) information first. In addition, if you plan to contribute with code it is also encouraged to read the [development](/DEVELOPMENT.md) guide.
|
||||
1
services/storage/VERSION
Normal file
1
services/storage/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.0.0-dev
|
||||
29
services/storage/api/datetime.go
Normal file
29
services/storage/api/datetime.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const format = time.RFC1123
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (dt *Time) UnmarshalText(text []byte) error {
|
||||
if len(text) == 0 || string(text) == `""` {
|
||||
*dt = Time(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(format, string(text))
|
||||
if err != nil {
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
*dt = Time(t)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Date(year int, month time.Month, day, hour, minute, sec, nsec int, loc *time.Location) Time {
|
||||
return Time(time.Date(year, month, day, hour, minute, sec, nsec, loc))
|
||||
}
|
||||
6
services/storage/api/server.cfg.yaml
Normal file
6
services/storage/api/server.cfg.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
package: api
|
||||
generate:
|
||||
gin-server: true
|
||||
embedded-spec: true
|
||||
strict-server: true
|
||||
output: api/server.gen.go
|
||||
2219
services/storage/api/server.gen.go
Normal file
2219
services/storage/api/server.gen.go
Normal file
File diff suppressed because it is too large
Load Diff
4
services/storage/api/types.cfg.yaml
Normal file
4
services/storage/api/types.cfg.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
package: api
|
||||
generate:
|
||||
models: true
|
||||
output: api/types.gen.go
|
||||
295
services/storage/api/types.gen.go
Normal file
295
services/storage/api/types.gen.go
Normal file
@@ -0,0 +1,295 @@
|
||||
// Package api provides primitives to interact with the openapi HTTP API.
|
||||
//
|
||||
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthorizationScopes = "Authorization.Scopes"
|
||||
X_Hasura_Admin_SecretScopes = "X_Hasura_Admin_Secret.Scopes"
|
||||
)
|
||||
|
||||
// Defines values for OutputImageFormat.
|
||||
const (
|
||||
Auto OutputImageFormat = "auto"
|
||||
Avif OutputImageFormat = "avif"
|
||||
Jpeg OutputImageFormat = "jpeg"
|
||||
Png OutputImageFormat = "png"
|
||||
Same OutputImageFormat = "same"
|
||||
Webp OutputImageFormat = "webp"
|
||||
)
|
||||
|
||||
// ErrorResponse Error information returned by the API.
|
||||
type ErrorResponse struct {
|
||||
// Error Error details.
|
||||
Error *struct {
|
||||
// Data Additional data related to the error, if any.
|
||||
Data *map[string]interface{} `json:"data,omitempty"`
|
||||
|
||||
// Message Human-readable error message.
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponseWithProcessedFiles Error information returned by the API.
|
||||
type ErrorResponseWithProcessedFiles struct {
|
||||
// Error Error details.
|
||||
Error *struct {
|
||||
// Data Additional data related to the error, if any.
|
||||
Data *map[string]interface{} `json:"data,omitempty"`
|
||||
|
||||
// Message Human-readable error message.
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
|
||||
// ProcessedFiles List of files that were successfully processed before the error occurred.
|
||||
ProcessedFiles *[]FileMetadata `json:"processedFiles,omitempty"`
|
||||
}
|
||||
|
||||
// FileMetadata Comprehensive metadata information about a file in storage.
|
||||
type FileMetadata struct {
|
||||
// BucketId ID of the bucket containing the file.
|
||||
BucketId string `json:"bucketId"`
|
||||
|
||||
// CreatedAt Timestamp when the file was created.
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// Etag Entity tag for cache validation.
|
||||
Etag string `json:"etag"`
|
||||
|
||||
// Id Unique identifier for the file.
|
||||
Id string `json:"id"`
|
||||
|
||||
// IsUploaded Whether the file has been successfully uploaded.
|
||||
IsUploaded bool `json:"isUploaded"`
|
||||
|
||||
// Metadata Custom metadata associated with the file.
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// MimeType MIME type of the file.
|
||||
MimeType string `json:"mimeType"`
|
||||
|
||||
// Name Name of the file including extension.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Size Size of the file in bytes.
|
||||
Size int64 `json:"size"`
|
||||
|
||||
// UpdatedAt Timestamp when the file was last updated.
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// UploadedByUserId ID of the user who uploaded the file.
|
||||
UploadedByUserId *string `json:"uploadedByUserId,omitempty"`
|
||||
}
|
||||
|
||||
// FileSummary Basic information about a file in storage.
|
||||
type FileSummary struct {
|
||||
// BucketId ID of the bucket containing the file.
|
||||
BucketId string `json:"bucketId"`
|
||||
|
||||
// Id Unique identifier for the file.
|
||||
Id string `json:"id"`
|
||||
|
||||
// IsUploaded Whether the file has been successfully uploaded.
|
||||
IsUploaded bool `json:"isUploaded"`
|
||||
|
||||
// Name Name of the file including extension.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// OutputImageFormat Output format for image files. Use 'auto' for content negotiation based on Accept header
|
||||
type OutputImageFormat string
|
||||
|
||||
// PresignedURLResponse Contains a presigned URL for direct file operations.
|
||||
type PresignedURLResponse struct {
|
||||
// Expiration The time in seconds until the URL expires.
|
||||
Expiration int `json:"expiration"`
|
||||
|
||||
// Url The presigned URL for file operations.
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
// RFC2822Date Date in RFC 2822 format
|
||||
type RFC2822Date = Time
|
||||
|
||||
// UpdateFileMetadata Metadata that can be updated for an existing file.
|
||||
type UpdateFileMetadata struct {
|
||||
// Metadata Updated custom metadata to associate with the file.
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// Name New name to assign to the file.
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// UploadFileMetadata Metadata provided when uploading a new file.
|
||||
type UploadFileMetadata struct {
|
||||
// Id Optional custom ID for the file. If not provided, a UUID will be generated.
|
||||
Id *string `json:"id,omitempty"`
|
||||
|
||||
// Metadata Custom metadata to associate with the file.
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// Name Name to assign to the file. If not provided, the original filename will be used.
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// VersionInformation Contains version information about the storage service.
|
||||
type VersionInformation struct {
|
||||
// BuildVersion The version number of the storage service build.
|
||||
BuildVersion string `json:"buildVersion"`
|
||||
}
|
||||
|
||||
// UploadFilesMultipartBody defines parameters for UploadFiles.
|
||||
type UploadFilesMultipartBody struct {
|
||||
// BucketId Target bucket identifier where files will be stored.
|
||||
BucketId *string `json:"bucket-id,omitempty"`
|
||||
|
||||
// File Array of files to upload.
|
||||
File []openapi_types.File `json:"file[]"`
|
||||
|
||||
// Metadata Optional custom metadata for each uploaded file. Must match the order of the file[] array.
|
||||
Metadata *[]UploadFileMetadata `json:"metadata[],omitempty"`
|
||||
}
|
||||
|
||||
// GetFileParams defines parameters for GetFile.
|
||||
type GetFileParams struct {
|
||||
// Q Image quality (1-100). Only applies to JPEG, WebP and PNG files
|
||||
Q *int `form:"q,omitempty" json:"q,omitempty"`
|
||||
|
||||
// H Maximum height to resize image to while maintaining aspect ratio. Only applies to image files
|
||||
H *int `form:"h,omitempty" json:"h,omitempty"`
|
||||
|
||||
// W Maximum width to resize image to while maintaining aspect ratio. Only applies to image files
|
||||
W *int `form:"w,omitempty" json:"w,omitempty"`
|
||||
|
||||
// B Blur the image using this sigma value. Only applies to image files
|
||||
B *float32 `form:"b,omitempty" json:"b,omitempty"`
|
||||
|
||||
// F Output format for image files. Use 'auto' for content negotiation based on Accept header
|
||||
F *OutputImageFormat `form:"f,omitempty" json:"f,omitempty"`
|
||||
|
||||
// IfMatch Only return the file if the current ETag matches one of the values provided
|
||||
IfMatch *string `json:"if-match,omitempty"`
|
||||
|
||||
// IfNoneMatch Only return the file if the current ETag does not match any of the values provided
|
||||
IfNoneMatch *string `json:"if-none-match,omitempty"`
|
||||
|
||||
// IfModifiedSince Only return the file if it has been modified after the given date
|
||||
IfModifiedSince *RFC2822Date `json:"if-modified-since,omitempty"`
|
||||
|
||||
// IfUnmodifiedSince Only return the file if it has not been modified after the given date
|
||||
IfUnmodifiedSince *RFC2822Date `json:"if-unmodified-since,omitempty"`
|
||||
|
||||
// Range Range of bytes to retrieve from the file. Format: bytes=start-end
|
||||
Range *string `json:"Range,omitempty"`
|
||||
}
|
||||
|
||||
// GetFileMetadataHeadersParams defines parameters for GetFileMetadataHeaders.
|
||||
type GetFileMetadataHeadersParams struct {
|
||||
// Q Image quality (1-100). Only applies to JPEG, WebP and PNG files
|
||||
Q *int `form:"q,omitempty" json:"q,omitempty"`
|
||||
|
||||
// H Maximum height to resize image to while maintaining aspect ratio. Only applies to image files
|
||||
H *int `form:"h,omitempty" json:"h,omitempty"`
|
||||
|
||||
// W Maximum width to resize image to while maintaining aspect ratio. Only applies to image files
|
||||
W *int `form:"w,omitempty" json:"w,omitempty"`
|
||||
|
||||
// B Blur the image using this sigma value. Only applies to image files
|
||||
B *float32 `form:"b,omitempty" json:"b,omitempty"`
|
||||
|
||||
// F Output format for image files. Use 'auto' for content negotiation based on Accept header
|
||||
F *OutputImageFormat `form:"f,omitempty" json:"f,omitempty"`
|
||||
|
||||
// IfMatch Only return the file if the current ETag matches one of the values provided
|
||||
IfMatch *string `json:"if-match,omitempty"`
|
||||
|
||||
// IfNoneMatch Only return the file if the current ETag does not match any of the values provided
|
||||
IfNoneMatch *string `json:"if-none-match,omitempty"`
|
||||
|
||||
// IfModifiedSince Only return the file if it has been modified after the given date
|
||||
IfModifiedSince *RFC2822Date `json:"if-modified-since,omitempty"`
|
||||
|
||||
// IfUnmodifiedSince Only return the file if it has not been modified after the given date
|
||||
IfUnmodifiedSince *RFC2822Date `json:"if-unmodified-since,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceFileMultipartBody defines parameters for ReplaceFile.
|
||||
type ReplaceFileMultipartBody struct {
|
||||
// File New file content to replace the existing file
|
||||
File *openapi_types.File `json:"file,omitempty"`
|
||||
|
||||
// Metadata Metadata that can be updated for an existing file.
|
||||
Metadata *UpdateFileMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// GetFileWithPresignedURLParams defines parameters for GetFileWithPresignedURL.
|
||||
type GetFileWithPresignedURLParams struct {
|
||||
// XAmzAlgorithm Use presignedurl endpoint to generate this automatically
|
||||
XAmzAlgorithm string `form:"X-Amz-Algorithm" json:"X-Amz-Algorithm"`
|
||||
|
||||
// XAmzCredential Use presignedurl endpoint to generate this automatically
|
||||
XAmzCredential string `form:"X-Amz-Credential" json:"X-Amz-Credential"`
|
||||
|
||||
// XAmzDate Use presignedurl endpoint to generate this automatically
|
||||
XAmzDate string `form:"X-Amz-Date" json:"X-Amz-Date"`
|
||||
|
||||
// XAmzExpires Use presignedurl endpoint to generate this automatically
|
||||
XAmzExpires string `form:"X-Amz-Expires" json:"X-Amz-Expires"`
|
||||
|
||||
// XAmzSignature Use presignedurl endpoint to generate this automatically
|
||||
XAmzSignature string `form:"X-Amz-Signature" json:"X-Amz-Signature"`
|
||||
|
||||
// XAmzSignedHeaders Use presignedurl endpoint to generate this automatically
|
||||
XAmzSignedHeaders string `form:"X-Amz-SignedHeaders" json:"X-Amz-SignedHeaders"`
|
||||
|
||||
// XAmzChecksumMode Use presignedurl endpoint to generate this automatically
|
||||
XAmzChecksumMode string `form:"X-Amz-Checksum-Mode" json:"X-Amz-Checksum-Mode"`
|
||||
|
||||
// XAmzSecurityToken Use presignedurl endpoint to generate this automatically
|
||||
XAmzSecurityToken *string `form:"X-Amz-Security-Token,omitempty" json:"X-Amz-Security-Token,omitempty"`
|
||||
|
||||
// XId Use presignedurl endpoint to generate this automatically
|
||||
XId string `form:"x-id" json:"x-id"`
|
||||
|
||||
// Q Image quality (1-100). Only applies to JPEG, WebP and PNG files
|
||||
Q *int `form:"q,omitempty" json:"q,omitempty"`
|
||||
|
||||
// H Maximum height to resize image to while maintaining aspect ratio. Only applies to image files
|
||||
H *int `form:"h,omitempty" json:"h,omitempty"`
|
||||
|
||||
// W Maximum width to resize image to while maintaining aspect ratio. Only applies to image files
|
||||
W *int `form:"w,omitempty" json:"w,omitempty"`
|
||||
|
||||
// B Blur the image using this sigma value. Only applies to image files
|
||||
B *float32 `form:"b,omitempty" json:"b,omitempty"`
|
||||
|
||||
// F Output format for image files. Use 'auto' for content negotiation based on Accept header
|
||||
F *OutputImageFormat `form:"f,omitempty" json:"f,omitempty"`
|
||||
|
||||
// IfMatch Only return the file if the current ETag matches one of the values provided
|
||||
IfMatch *string `json:"if-match,omitempty"`
|
||||
|
||||
// IfNoneMatch Only return the file if the current ETag does not match any of the values provided
|
||||
IfNoneMatch *string `json:"if-none-match,omitempty"`
|
||||
|
||||
// IfModifiedSince Only return the file if it has been modified after the given date
|
||||
IfModifiedSince *RFC2822Date `json:"if-modified-since,omitempty"`
|
||||
|
||||
// IfUnmodifiedSince Only return the file if it has not been modified after the given date
|
||||
IfUnmodifiedSince *RFC2822Date `json:"if-unmodified-since,omitempty"`
|
||||
|
||||
// Range Range of bytes to retrieve from the file. Format: bytes=start-end
|
||||
Range *string `json:"Range,omitempty"`
|
||||
}
|
||||
|
||||
// UploadFilesMultipartRequestBody defines body for UploadFiles for multipart/form-data ContentType.
|
||||
type UploadFilesMultipartRequestBody UploadFilesMultipartBody
|
||||
|
||||
// ReplaceFileMultipartRequestBody defines body for ReplaceFile for multipart/form-data ContentType.
|
||||
type ReplaceFileMultipartRequestBody ReplaceFileMultipartBody
|
||||
148
services/storage/api/types.manual.go
Normal file
148
services/storage/api/types.manual.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
// GetQ returns the Q field value.
|
||||
func (g GetFileParams) GetQ() *int {
|
||||
return g.Q
|
||||
}
|
||||
|
||||
// GetH returns the H field value.
|
||||
func (g GetFileParams) GetH() *int {
|
||||
return g.H
|
||||
}
|
||||
|
||||
// GetW returns the W field value.
|
||||
func (g GetFileParams) GetW() *int {
|
||||
return g.W
|
||||
}
|
||||
|
||||
// GetB returns the B field value.
|
||||
func (g GetFileParams) GetB() *float32 {
|
||||
return g.B
|
||||
}
|
||||
|
||||
// GetF returns the F field value.
|
||||
func (g GetFileParams) GetF() *OutputImageFormat {
|
||||
return g.F
|
||||
}
|
||||
|
||||
func (g GetFileParams) HasImageManipulationOptions() bool {
|
||||
return g.Q != nil || g.H != nil || g.W != nil || g.B != nil || g.F != nil
|
||||
}
|
||||
|
||||
// GetIfMatch returns the IfMatch field value.
|
||||
func (g GetFileParams) GetIfMatch() *string {
|
||||
return g.IfMatch
|
||||
}
|
||||
|
||||
// GetIfNoneMatch returns the IfNoneMatch field value.
|
||||
func (g GetFileParams) GetIfNoneMatch() *string {
|
||||
return g.IfNoneMatch
|
||||
}
|
||||
|
||||
// GetIfModifiedSince returns the IfModifiedSince field value.
|
||||
func (g GetFileParams) GetIfModifiedSince() *Time {
|
||||
return g.IfModifiedSince
|
||||
}
|
||||
|
||||
// GetIfUnmodifiedSince returns the IfUnmodifiedSince field value.
|
||||
func (g GetFileParams) GetIfUnmodifiedSince() *Time {
|
||||
return g.IfUnmodifiedSince
|
||||
}
|
||||
|
||||
// GetQ returns the Q field value.
|
||||
func (g GetFileMetadataHeadersParams) GetQ() *int {
|
||||
return g.Q
|
||||
}
|
||||
|
||||
// GetH returns the H field value.
|
||||
func (g GetFileMetadataHeadersParams) GetH() *int {
|
||||
return g.H
|
||||
}
|
||||
|
||||
// GetW returns the W field value.
|
||||
func (g GetFileMetadataHeadersParams) GetW() *int {
|
||||
return g.W
|
||||
}
|
||||
|
||||
// GetB returns the B field value.
|
||||
func (g GetFileMetadataHeadersParams) GetB() *float32 {
|
||||
return g.B
|
||||
}
|
||||
|
||||
// GetF returns the F field value.
|
||||
func (g GetFileMetadataHeadersParams) GetF() *OutputImageFormat {
|
||||
return g.F
|
||||
}
|
||||
|
||||
func (g GetFileMetadataHeadersParams) HasImageManipulationOptions() bool {
|
||||
return g.Q != nil || g.H != nil || g.W != nil || g.B != nil || g.F != nil
|
||||
}
|
||||
|
||||
// GetIfMatch returns the IfMatch field value.
|
||||
func (g GetFileMetadataHeadersParams) GetIfMatch() *string {
|
||||
return g.IfMatch
|
||||
}
|
||||
|
||||
// GetIfNoneMatch returns the IfNoneMatch field value.
|
||||
func (g GetFileMetadataHeadersParams) GetIfNoneMatch() *string {
|
||||
return g.IfNoneMatch
|
||||
}
|
||||
|
||||
// GetIfModifiedSince returns the IfModifiedSince field value.
|
||||
func (g GetFileMetadataHeadersParams) GetIfModifiedSince() *Time {
|
||||
return g.IfModifiedSince
|
||||
}
|
||||
|
||||
// GetIfUnmodifiedSince returns the IfUnmodifiedSince field value.
|
||||
func (g GetFileMetadataHeadersParams) GetIfUnmodifiedSince() *Time {
|
||||
return g.IfUnmodifiedSince
|
||||
}
|
||||
|
||||
// GetQ returns the Q field value.
|
||||
func (g GetFileWithPresignedURLParams) GetQ() *int {
|
||||
return g.Q
|
||||
}
|
||||
|
||||
// GetH returns the H field value.
|
||||
func (g GetFileWithPresignedURLParams) GetH() *int {
|
||||
return g.H
|
||||
}
|
||||
|
||||
// GetW returns the W field value.
|
||||
func (g GetFileWithPresignedURLParams) GetW() *int {
|
||||
return g.W
|
||||
}
|
||||
|
||||
// GetB returns the B field value.
|
||||
func (g GetFileWithPresignedURLParams) GetB() *float32 {
|
||||
return g.B
|
||||
}
|
||||
|
||||
// GetF returns the F field value.
|
||||
func (g GetFileWithPresignedURLParams) GetF() *OutputImageFormat {
|
||||
return g.F
|
||||
}
|
||||
|
||||
func (g GetFileWithPresignedURLParams) HasImageManipulationOptions() bool {
|
||||
return g.Q != nil || g.H != nil || g.W != nil || g.B != nil || g.F != nil
|
||||
}
|
||||
|
||||
// GetIfMatch returns the IfMatch field value.
|
||||
func (g GetFileWithPresignedURLParams) GetIfMatch() *string {
|
||||
return g.IfMatch
|
||||
}
|
||||
|
||||
// GetIfNoneMatch returns the IfNoneMatch field value.
|
||||
func (g GetFileWithPresignedURLParams) GetIfNoneMatch() *string {
|
||||
return g.IfNoneMatch
|
||||
}
|
||||
|
||||
// GetIfModifiedSince returns the IfModifiedSince field value.
|
||||
func (g GetFileWithPresignedURLParams) GetIfModifiedSince() *Time {
|
||||
return g.IfModifiedSince
|
||||
}
|
||||
|
||||
// GetIfUnmodifiedSince returns the IfUnmodifiedSince field value.
|
||||
func (g GetFileWithPresignedURLParams) GetIfUnmodifiedSince() *Time {
|
||||
return g.IfUnmodifiedSince
|
||||
}
|
||||
14
services/storage/build/clamav/clamd.conf.tmpl
Normal file
14
services/storage/build/clamav/clamd.conf.tmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
Foreground yes
|
||||
|
||||
DatabaseDirectory /clamav/db
|
||||
|
||||
TCPSocket 3310
|
||||
|
||||
MaxScanSize 1024M
|
||||
MaxFileSize 1024M
|
||||
StreamMaxLength 1024M
|
||||
|
||||
MaxRecursion 16
|
||||
MaxFiles 10000
|
||||
|
||||
SelfCheck 1800
|
||||
35
services/storage/build/clamav/entrypoint.sh
Normal file
35
services/storage/build/clamav/entrypoint.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p /clamav
|
||||
|
||||
envsubst < /etc/clamav/freshclam.conf.tmpl > /etc/clamav/freshclam.conf
|
||||
envsubst < /etc/clamav/clamd.conf.tmpl > /etc/clamav/clamd.conf
|
||||
|
||||
# we run freshclam first to download the database
|
||||
freshclam
|
||||
# we start the freshclam daemon
|
||||
freshclam -d &
|
||||
pid1=$!
|
||||
|
||||
# we start the clamd daemon
|
||||
clamd &
|
||||
pid2=$!
|
||||
|
||||
# Loop until either process finishes
|
||||
while true; do
|
||||
if kill -0 $pid1 >/dev/null 2>&1; then
|
||||
if kill -0 $pid2 >/dev/null 2>&1; then
|
||||
sleep 5
|
||||
else
|
||||
kill $pid1
|
||||
break
|
||||
fi
|
||||
else
|
||||
kill $pid2
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
exit 1
|
||||
4
services/storage/build/clamav/freshclam.conf.tmpl
Normal file
4
services/storage/build/clamav/freshclam.conf.tmpl
Normal file
@@ -0,0 +1,4 @@
|
||||
DatabaseDirectory /clamav/db
|
||||
Foreground yes
|
||||
DatabaseOwner root
|
||||
DatabaseMirror ${DATABASE_MIRROR:-database.clamav.net}
|
||||
7
services/storage/build/dev/docker/Makefile
Normal file
7
services/storage/build/dev/docker/Makefile
Normal file
@@ -0,0 +1,7 @@
|
||||
.PHONY: dev-env-build
|
||||
dev-env-build:
|
||||
docker-compose build
|
||||
|
||||
.PHONY: dev-env-start
|
||||
dev-env-start: dev-env-build
|
||||
docker-compose up -d
|
||||
72
services/storage/build/dev/docker/docker-compose.yaml
Normal file
72
services/storage/build/dev/docker/docker-compose.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
services:
|
||||
postgres:
|
||||
container_name: storage-postgres
|
||||
image: postgres:13
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./initdb.d:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hejsan}
|
||||
|
||||
graphql-engine:
|
||||
container_name: storage-graphql
|
||||
image: nhost/graphql-engine:v2.25.1-ce
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
HASURA_GRAPHQL_DATABASE_URL: ${HASURA_GRAPHQL_DATABASE_URL:-postgres://postgres:hejsan@postgres:5432/postgres}
|
||||
HASURA_GRAPHQL_ENABLE_CONSOLE: ${HASURA_GRAPHQL_ENABLE_CONSOLE:-true}
|
||||
HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET:-nhost-admin-secret}
|
||||
HASURA_GRAPHQL_JWT_SECRET: ${HASURA_GRAPHQL_JWT_SECRET:-{"type":"HS256", "key":"5152fa850c02dc222631cca898ed1485821a70912a6e3649c49076912daa3b62182ba013315915d64f40cddfbb8b58eb5bd11ba225336a6af45bbae07ca873f3"}}
|
||||
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: ${HASURA_GRAPHQL_UNAUTHORIZED_ROLE:-public}
|
||||
HASURA_GRAPHQL_LOG_LEVEL: ${HASURA_GRAPHQL_LOG_LEVEL:-info}
|
||||
HASURA_GRAPHQL_DEV_MODE: ${HASURA_GRAPHQL_DEV_MODE:-false}
|
||||
|
||||
minio:
|
||||
container_name: storage-minio
|
||||
build:
|
||||
context: minio
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-5a7bdb5f42c41e0622bf61d6e08d5537}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8}
|
||||
command: server --address 0.0.0.0:9000 --console-address 0.0.0.0:32765 /tmp
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '32765:32765'
|
||||
|
||||
storage:
|
||||
container_name: storage-storage
|
||||
image: storage:0.0.0-dev
|
||||
depends_on:
|
||||
- graphql-engine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8000:8000'
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
HASURA_METADATA: 1
|
||||
HASURA_ENDPOINT: http://graphql-engine:8080/v1
|
||||
HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET:-nhost-admin-secret}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-5a7bdb5f42c41e0622bf61d6e08d5537}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8}
|
||||
S3_BUCKET: "default"
|
||||
S3_ROOT_FOLDER: "f215cf48-7458-4596-9aa5-2159fc6a3caf"
|
||||
POSTGRES_MIGRATIONS: 1
|
||||
POSTGRES_MIGRATIONS_SOURCE: ${HASURA_GRAPHQL_DATABASE_URL:-postgres://postgres:hejsan@postgres:5432/postgres?sslmode=disable}
|
||||
CLAMAV_SERVER: tcp://clamd:3310
|
||||
command: serve
|
||||
|
||||
clamd:
|
||||
container_name: storage-clamd
|
||||
image: nhost/clamav:0.1.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3310:3310'
|
||||
@@ -0,0 +1,5 @@
|
||||
-- auth schema
|
||||
CREATE SCHEMA IF NOT EXISTS storage;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
|
||||
5
services/storage/build/dev/docker/minio/Dockerfile
Normal file
5
services/storage/build/dev/docker/minio/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM minio/minio:RELEASE.2025-02-28T09-55-16Z
|
||||
|
||||
ADD init.sh /usr/bin/init.sh
|
||||
|
||||
ENTRYPOINT ["/usr/bin/init.sh"]
|
||||
14
services/storage/build/dev/docker/minio/init.sh
Executable file
14
services/storage/build/dev/docker/minio/init.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
/usr/bin/docker-entrypoint.sh "$@" &
|
||||
|
||||
sleep 3
|
||||
|
||||
mc alias set myminio http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
|
||||
mc mb myminio/default
|
||||
|
||||
dd if=/dev/random of=/tmp/asd bs=64k count=1
|
||||
mc cp /tmp/asd myminio/default/f215cf48-7458-4596-9aa5-2159fc6a3caf/default/asd
|
||||
mc cp /tmp/asd myminio/default/this-shouldnt-show-in-list/asd
|
||||
|
||||
sleep infinity
|
||||
6
services/storage/build/dev/jwt-gen/get-jwt.sh
Executable file
6
services/storage/build/dev/jwt-gen/get-jwt.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#1/bin/sh
|
||||
cd $(dirname $0)
|
||||
|
||||
JWT_SECRET=$(docker exec hasura-storage-graphql bash -c 'echo "$HASURA_GRAPHQL_JWT_SECRET"')
|
||||
|
||||
go run main.go -jwt-secret "$JWT_SECRET"
|
||||
5
services/storage/build/dev/jwt-gen/go.mod
Normal file
5
services/storage/build/dev/jwt-gen/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module jwt-gen
|
||||
|
||||
go 1.17
|
||||
|
||||
require github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1
|
||||
4
services/storage/build/dev/jwt-gen/go.sum
Normal file
4
services/storage/build/dev/jwt-gen/go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU=
|
||||
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
55
services/storage/build/dev/jwt-gen/main.go
Normal file
55
services/storage/build/dev/jwt-gen/main.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go/v4"
|
||||
)
|
||||
|
||||
type jwtSecret struct {
|
||||
Key string `json:"key"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// docker-compose -f build/dev/docker/docker-compose.yaml exec graphql-engine bash -c 'echo "$HASURA_GRAPHQL_JWT_SECRET" | cut -d\" -f8'
|
||||
jwtSecretF := flag.String("jwt-secret", "", "JWT secret")
|
||||
flag.Parse()
|
||||
|
||||
jSecret := jwtSecret{}
|
||||
if err := json.Unmarshal([]byte(*jwtSecretF), &jSecret); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// output of
|
||||
mySigningKey := []byte(jSecret.Key)
|
||||
|
||||
now := time.Now()
|
||||
iat := now.Unix()
|
||||
exp := now.Add(24 * 365 * 10 * time.Hour).Unix()
|
||||
|
||||
// Create the Claims
|
||||
claims := &jwt.MapClaims{
|
||||
"sub": "ab5ba58e-932a-40dc-87e8-733998794ec2",
|
||||
"iss": "hasura-auth",
|
||||
"iat": iat,
|
||||
"exp": exp,
|
||||
"https://hasura.io/jwt/claims": map[string]interface{}{
|
||||
"x-hasura-allowed-roles": []string{
|
||||
"admin",
|
||||
},
|
||||
"x-hasura-default-role": "admin",
|
||||
"x-hasura-user-id": "ab5ba58e-932a-40dc-87e8-733998794ec2",
|
||||
"x-hasura-user-isAnonymous": "false",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
ss, err := token.SignedString(mySigningKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Print(ss)
|
||||
}
|
||||
36
services/storage/build/nix-docker-image.sh
Executable file
36
services/storage/build/nix-docker-image.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
which nix > /dev/null
|
||||
|
||||
IMAGE=${1:-"docker-image"}
|
||||
SYSTEM=${2:-""}
|
||||
|
||||
if [[ $NIX_BUILD_NATIVE -eq 1 ]]; then
|
||||
if [[ -z $SYSTEM ]]; then
|
||||
case $(uname -m) in
|
||||
"arm64")
|
||||
SYSTEM="aarch64-linux"
|
||||
;;
|
||||
*)
|
||||
SYSTEM="x86_64-linux"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
nix build .\#packages.${SYSTEM}.$IMAGE --print-build-logs
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if [[ ( $? -eq 0 ) && ( `uname` == "Linux" ) ]]; then
|
||||
nix build .\#$IMAGE --print-build-logs
|
||||
exit $?
|
||||
fi
|
||||
|
||||
|
||||
docker run --rm -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v $PWD:/build \
|
||||
-w /build \
|
||||
--entrypoint sh \
|
||||
dbarroso/nix:2.6.0 \
|
||||
-c "nix build .\\#$IMAGE --print-build-logs && docker load < result"
|
||||
16
services/storage/build/nix.sh
Executable file
16
services/storage/build/nix.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
which nix > /dev/null
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
nix $@
|
||||
exit $?
|
||||
fi
|
||||
|
||||
docker run --rm -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v $PWD:/build \
|
||||
-w /build \
|
||||
--entrypoint nix \
|
||||
dbarroso/nix:2.6.0 \
|
||||
"$@"
|
||||
92
services/storage/clamd/clamd.go
Normal file
92
services/storage/clamd/clamd.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package clamd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const chunkSize = 1024
|
||||
|
||||
type Client struct {
|
||||
addr string
|
||||
}
|
||||
|
||||
func NewClient(addr string) (*Client, error) {
|
||||
url, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse addr: %w", err)
|
||||
}
|
||||
|
||||
if url.Scheme != "tcp" {
|
||||
return nil, fmt.Errorf("invalid scheme: %s", url.Scheme) //nolint:err113
|
||||
}
|
||||
|
||||
return &Client{url.Host}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Dial(ctx context.Context) (net.Conn, error) {
|
||||
dialer := net.Dialer{ //nolint:exhaustruct
|
||||
Deadline: time.Now().Add(1 * time.Minute),
|
||||
Timeout: time.Minute,
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(ctx, "tcp", c.addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func sendCommand(conn net.Conn, command string) error {
|
||||
if _, err := fmt.Fprintf(conn,
|
||||
"n%s\n", command); err != nil {
|
||||
return fmt.Errorf("failed to write command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readResponse(conn net.Conn) ([]byte, error) {
|
||||
buf := make([]byte, 1024) //nolint:mnd
|
||||
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return buf[:n], nil
|
||||
}
|
||||
|
||||
func sendChunk(conn net.Conn, data []byte) error {
|
||||
var buf [4]byte
|
||||
|
||||
lenData := len(data)
|
||||
buf[0] = byte(lenData >> 24) //nolint:mnd
|
||||
buf[1] = byte(lenData >> 16) //nolint:mnd
|
||||
buf[2] = byte(lenData >> 8) //nolint:mnd
|
||||
buf[3] = byte(lenData >> 0)
|
||||
|
||||
a := buf
|
||||
|
||||
b := make([]byte, len(a))
|
||||
copy(b, a[:])
|
||||
|
||||
if _, err := conn.Write(b); err != nil {
|
||||
return fmt.Errorf("failed to write chunk size: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Write(data); err != nil {
|
||||
return fmt.Errorf("failed to write chunk: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendEOF(conn net.Conn) error {
|
||||
_, err := conn.Write([]byte{0, 0, 0, 0})
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
9
services/storage/clamd/errors.go
Normal file
9
services/storage/clamd/errors.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package clamd
|
||||
|
||||
type VirusFoundError struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e *VirusFoundError) Error() string {
|
||||
return "virus found: " + e.Name
|
||||
}
|
||||
58
services/storage/clamd/instream.go
Normal file
58
services/storage/clamd/instream.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package clamd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func (c *Client) InStream(ctx context.Context, r io.ReaderAt) error { //nolint: cyclop
|
||||
conn, err := c.Dial(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := sendCommand(conn, "INSTREAM"); err != nil {
|
||||
return fmt.Errorf("failed to send INSTREAM command: %w", err)
|
||||
}
|
||||
|
||||
var iter int64
|
||||
|
||||
for {
|
||||
buf := make([]byte, chunkSize)
|
||||
|
||||
nr, err := r.ReadAt(buf, iter*chunkSize)
|
||||
iter++
|
||||
|
||||
if nr > 0 {
|
||||
if err := sendChunk(conn, buf[0:nr]); err != nil {
|
||||
return fmt.Errorf("failed to send chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sendEOF(conn); err != nil {
|
||||
return fmt.Errorf("failed to send EOF: %w", err)
|
||||
}
|
||||
|
||||
response, err := readResponse(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if string(response) == "stream: OK\n" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &VirusFoundError{string(response[8 : len(response)-7])}
|
||||
}
|
||||
53
services/storage/clamd/instream_test.go
Normal file
53
services/storage/clamd/instream_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package clamd_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/nhost/nhost/services/storage/clamd"
|
||||
)
|
||||
|
||||
func TestClamdInstream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
filepath string
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "clean",
|
||||
filepath: "clamd.go",
|
||||
},
|
||||
{
|
||||
name: "eicarcom2.zip",
|
||||
filepath: "testdata/eicarcom2.zip",
|
||||
expectedError: &clamd.VirusFoundError{
|
||||
Name: "Win.Test.EICAR_HDB-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := clamd.NewClient("tcp://localhost:3310")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(tc.filepath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = client.InStream(t.Context(), f)
|
||||
if diff := cmp.Diff(tc.expectedError, err); diff != "" {
|
||||
t.Errorf("unexpected error (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
services/storage/clamd/ping.go
Normal file
29
services/storage/clamd/ping.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package clamd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
conn, err := c.Dial(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := sendCommand(conn, "PING"); err != nil {
|
||||
return fmt.Errorf("failed to send PING command: %w", err)
|
||||
}
|
||||
|
||||
response, err := readResponse(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if string(response) != "PONG\n" {
|
||||
return fmt.Errorf("unknown response: %s", string(response)) //nolint:err113
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
34
services/storage/clamd/ping_test.go
Normal file
34
services/storage/clamd/ping_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package clamd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nhost/nhost/services/storage/clamd"
|
||||
)
|
||||
|
||||
func TestClamdPing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := clamd.NewClient("tcp://localhost:3310")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
|
||||
if err := client.Ping(t.Context()); err != nil {
|
||||
t.Fatalf("failed to get version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
services/storage/clamd/reload.go
Normal file
29
services/storage/clamd/reload.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package clamd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (c *Client) Reload(ctx context.Context) error {
|
||||
conn, err := c.Dial(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := sendCommand(conn, "RELOAD"); err != nil {
|
||||
return fmt.Errorf("failed to send RELOAD command: %w", err)
|
||||
}
|
||||
|
||||
response, err := readResponse(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if string(response) != "RELOADING\n" {
|
||||
return fmt.Errorf("unknown response: %s", string(response)) //nolint:err113
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
34
services/storage/clamd/reload_test.go
Normal file
34
services/storage/clamd/reload_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package clamd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nhost/nhost/services/storage/clamd"
|
||||
)
|
||||
|
||||
func TestClamdReload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := clamd.NewClient("tcp://localhost:3310")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
|
||||
if err := client.Reload(t.Context()); err != nil {
|
||||
t.Fatalf("failed to get version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
services/storage/clamd/testdata/eicarcom2.zip
vendored
Normal file
BIN
services/storage/clamd/testdata/eicarcom2.zip
vendored
Normal file
Binary file not shown.
38
services/storage/clamd/version.go
Normal file
38
services/storage/clamd/version.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package clamd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Version struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
func parseVersion(response []byte) Version {
|
||||
parts := strings.SplitN(string(response), " ", 2) //nolint:mnd
|
||||
|
||||
return Version{
|
||||
Version: parts[1],
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Version(ctx context.Context) (Version, error) {
|
||||
conn, err := c.Dial(ctx)
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := sendCommand(conn, "VERSION"); err != nil {
|
||||
return Version{}, fmt.Errorf("failed to send VERSION command: %w", err)
|
||||
}
|
||||
|
||||
response, err := readResponse(conn)
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return parseVersion(response), nil
|
||||
}
|
||||
43
services/storage/clamd/version_test.go
Normal file
43
services/storage/clamd/version_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package clamd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nhost/nhost/services/storage/clamd"
|
||||
)
|
||||
|
||||
func TestClamdVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expected clamd.Version
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
expected: clamd.Version{
|
||||
Version: "1.1.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := clamd.NewClient("tcp://localhost:3310")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
|
||||
version, err := client.Version(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get version: %v", err)
|
||||
}
|
||||
|
||||
if version.Version == "" {
|
||||
t.Fatalf("version.Version is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2506
services/storage/client/client.gen.go
Normal file
2506
services/storage/client/client.gen.go
Normal file
File diff suppressed because it is too large
Load Diff
2
services/storage/client/client.go
Normal file
2
services/storage/client/client.go
Normal file
@@ -0,0 +1,2 @@
|
||||
//go:generate oapi-codegen -generate types,client -response-type-suffix R -package client -o client.gen.go ../controller/openapi.yaml
|
||||
package client
|
||||
46
services/storage/client/client_test.go
Normal file
46
services/storage/client/client_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
const testBaseURL = "http://localhost:8000/v1"
|
||||
|
||||
const accessTokenValidUser = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwNjg0NDA4NTEsImh0dHBzOi8vaGFzdXJhLmlvL2p3dC9jbGFpbXMiOnsieC1oYXN1cmEtYWxsb3dlZC1yb2xlcyI6WyJhZG1pbiJdLCJ4LWhhc3VyYS1kZWZhdWx0LXJvbGUiOiJhZG1pbiIsIngtaGFzdXJhLXVzZXItaWQiOiJhYjViYTU4ZS05MzJhLTQwZGMtODdlOC03MzM5OTg3OTRlYzIiLCJ4LWhhc3VyYS11c2VyLWlzQW5vbnltb3VzIjoiZmFsc2UifSwiaWF0IjoxNzUzMDgwODUxLCJpc3MiOiJoYXN1cmEtYXV0aCIsInN1YiI6ImFiNWJhNThlLTkzMmEtNDBkYy04N2U4LTczMzk5ODc5NGVjMiJ9.jxx_ve7Ikw1eZrcxYzEuARqkKwiuAhTgCxc2VPvnONY` //nolint:gosec,lll
|
||||
|
||||
const eicarTestFile = `X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*`
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func WithAccessToken(accessToken string) func(ctx context.Context, req *http.Request) error {
|
||||
return func(_ context.Context, req *http.Request) error {
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithHeaders(headers http.Header) func(ctx context.Context, req *http.Request) error {
|
||||
return func(_ context.Context, req *http.Request) error {
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func IgnoreResponseHeaders() cmp.Option {
|
||||
return cmpopts.IgnoreMapEntries(func(key string, _ []string) bool {
|
||||
return key == "Date" || key == "Surrogate-Key" || key == "Last-Modified" ||
|
||||
strings.HasPrefix(key, "X-B3-")
|
||||
})
|
||||
}
|
||||
23
services/storage/client/datetime.go
Normal file
23
services/storage/client/datetime.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const format = time.RFC1123
|
||||
|
||||
type Time struct {
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func NewTime(t time.Time) Time {
|
||||
return Time{t: t}
|
||||
}
|
||||
|
||||
func (dt Time) MarshalText() ([]byte, error) {
|
||||
if dt.t.IsZero() {
|
||||
return []byte(`""`), nil
|
||||
}
|
||||
|
||||
return []byte(dt.t.Format(format)), nil
|
||||
}
|
||||
145
services/storage/client/delete_file_test.go
Normal file
145
services/storage/client/delete_file_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func TestDeleteFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
uploadInitialFile(t, cl, id1, id2)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expectedStatusCode int
|
||||
expectedHeader http.Header
|
||||
expectedCmpOpts []cmp.Option
|
||||
expectedErr *client.ErrorResponse
|
||||
}{
|
||||
{
|
||||
name: "simple delete",
|
||||
id: id1,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNoContent,
|
||||
expectedHeader: http.Header{
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong id",
|
||||
id: uuid.NewString(),
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotFound,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"51"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"file not found"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: nil,
|
||||
Message: "file not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no permissions",
|
||||
id: uuid.NewString(),
|
||||
interceptor: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"59"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: nil,
|
||||
Message: "you are not authorized",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cl.DeleteFileWithResponse(
|
||||
t.Context(),
|
||||
tc.id,
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete file: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
opts := append(
|
||||
cmp.Options{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "CreatedAt", "UpdatedAt"),
|
||||
},
|
||||
tc.expectedCmpOpts...,
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(resp.JSONDefault, tc.expectedErr, opts); diff != "" {
|
||||
t.Errorf("unexpected error response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeader,
|
||||
IgnoreResponseHeaders(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
333
services/storage/client/get_file_metadata_headers_test.go
Normal file
333
services/storage/client/get_file_metadata_headers_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func TestGetFileMetadataHeaders(t *testing.T) { //nolint:maintidx
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
uploadInitialFile(t, cl, id1, id2)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
params *client.GetFileMetadataHeadersParams
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expectedStatusCode int
|
||||
expectedHeaders http.Header
|
||||
}{
|
||||
{
|
||||
name: "simple get",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfMatch matches",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfMatch: ptr(`"65a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfMatch does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfMatch: ptr(`"85a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPreconditionFailed,
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfNoneMatch matches",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfNoneMatch: ptr(`"65a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotModified,
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfNoneMatch does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfNoneMatch: ptr(`"85a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfModifiedSince matches",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfModifiedSince: ptr(client.NewTime(time.Now().Add(-time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfModifiedSince does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfModifiedSince: ptr(client.NewTime(time.Now().Add(time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotModified,
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfUnmodifiedSince matches",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfUnmodifiedSince: ptr(client.NewTime(time.Now().Add(-time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPreconditionFailed,
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfUnmodifiedSince does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
IfUnmodifiedSince: ptr(client.NewTime(time.Now().Add(time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-admin-secret",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-role",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
"x-hasura-role": []string{"user"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeaders: http.Header{
|
||||
"Date": {"Mon, 21 Jul 2025 13:44:26 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unauthenticated request",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeaders: http.Header{
|
||||
"Date": {"Mon, 21 Jul 2025 13:44:26 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
id: id2,
|
||||
params: nil,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="nhost.jpg"`},
|
||||
"Content-Length": []string{"33399"},
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"78b676e65ebc31f0bb1f2f0d05098572"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{id2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image manipulation",
|
||||
id: id2,
|
||||
params: &client.GetFileMetadataHeadersParams{
|
||||
Q: ptr(80),
|
||||
H: ptr(100),
|
||||
W: ptr(100),
|
||||
B: ptr(float32(0.10)),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="nhost.jpg"`},
|
||||
"Content-Length": []string{"8709"},
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"78b676e65ebc31f0bb1f2f0d05098572"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{id2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cl.GetFileMetadataHeadersWithResponse(
|
||||
t.Context(),
|
||||
tc.id,
|
||||
tc.params,
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeaders,
|
||||
IgnoreResponseHeaders(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected response headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
173
services/storage/client/get_file_presigned_url_test.go
Normal file
173
services/storage/client/get_file_presigned_url_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func compareURLWithRegexp() cmp.Option {
|
||||
return cmp.FilterPath(
|
||||
func(p cmp.Path) bool {
|
||||
return p.Last().String() == `.Url`
|
||||
},
|
||||
cmp.Comparer(func(a, b string) bool {
|
||||
reg1 := regexp.MustCompile(a)
|
||||
reg2 := regexp.MustCompile(b)
|
||||
|
||||
return reg1.MatchString(b) || reg2.MatchString(a)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestGetFilePresignedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
uploadInitialFile(t, cl, id1, id2)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expectedStatusCode int
|
||||
expected *client.PresignedURLResponse
|
||||
expectedErr *client.ErrorResponse
|
||||
expectedHeaders http.Header
|
||||
}{
|
||||
{
|
||||
name: "simple get",
|
||||
id: id1,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expected: &client.PresignedURLResponse{
|
||||
Expiration: 30,
|
||||
Url: "http://localhost:8000/v1/files/.*/presignedurl/content?.*",
|
||||
},
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Length": {"470"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-admin-secret",
|
||||
id: id1,
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expected: &client.PresignedURLResponse{
|
||||
Expiration: 30,
|
||||
Url: "http://localhost:8000/v1/files/.*/presignedurl/content?.*",
|
||||
},
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Length": {"470"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-role",
|
||||
id: id1,
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
"x-hasura-role": []string{"user"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expected: nil,
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: "you are not authorized",
|
||||
},
|
||||
},
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Length": {"59"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unauthenticated request",
|
||||
id: id1,
|
||||
interceptor: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expected: nil,
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: "you are not authorized",
|
||||
},
|
||||
},
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Length": {"59"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cl.GetFilePresignedURLWithResponse(
|
||||
t.Context(),
|
||||
tc.id,
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(resp.JSON200, tc.expected, compareURLWithRegexp()); diff != "" {
|
||||
t.Errorf("unexpected response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(resp.JSONDefault, tc.expectedErr); diff != "" {
|
||||
t.Errorf("unexpected error response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeaders,
|
||||
compareContentLength(),
|
||||
IgnoreResponseHeaders(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
455
services/storage/client/get_file_test.go
Normal file
455
services/storage/client/get_file_test.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func uploadInitialFile(t *testing.T, cl client.ClientWithResponsesInterface, id1, id2 string) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.OpenFile("testdata/nhost.jpg", os.O_RDONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"default",
|
||||
client.NewFile(
|
||||
"testfile.txt",
|
||||
strings.NewReader("Hello, World!"),
|
||||
&client.UploadFileMetadata{
|
||||
Id: ptr(id1),
|
||||
},
|
||||
),
|
||||
client.NewFile(
|
||||
"nhost.jpg",
|
||||
f,
|
||||
&client.UploadFileMetadata{
|
||||
Id: ptr(id2),
|
||||
},
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
resp, err := cl.UploadFilesWithBodyWithResponse(
|
||||
t.Context(),
|
||||
contentType,
|
||||
body,
|
||||
WithAccessToken(accessTokenValidUser),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upload files: %v", err)
|
||||
}
|
||||
|
||||
if resp.JSONDefault != nil {
|
||||
t.Fatalf("unexpected error response: %v", resp.JSONDefault)
|
||||
}
|
||||
|
||||
if len(resp.JSON201.ProcessedFiles) == 0 {
|
||||
t.Fatal("no files were processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile(t *testing.T) { //nolint:maintidx
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
uploadInitialFile(t, cl, id1, id2)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
params *client.GetFileParams
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expectedStatusCode int
|
||||
expectedBody string
|
||||
expectedHeaders http.Header
|
||||
}{
|
||||
{
|
||||
name: "simple get",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfMatch matches",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfMatch: ptr(`"65a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfMatch does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfMatch: ptr(`"85a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPreconditionFailed,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Length": []string{"0"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfNoneMatch matches",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfNoneMatch: ptr(`"65a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotModified,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfNoneMatch does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfNoneMatch: ptr(`"85a8e27d8879283831b664bd8b7f0ad4"`),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfModifiedSince matches",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfModifiedSince: ptr(client.NewTime(time.Now().Add(-time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfModifiedSince does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfModifiedSince: ptr(client.NewTime(time.Now().Add(time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotModified,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfUnmodifiedSince matches",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfUnmodifiedSince: ptr(client.NewTime(time.Now().Add(-time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPreconditionFailed,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Length": []string{"0"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfUnmodifiedSince does not match",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
IfUnmodifiedSince: ptr(client.NewTime(time.Now().Add(time.Hour))),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-admin-secret",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-role",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
"x-hasura-role": []string{"user"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedBody: "{\"error\":{\"data\":null,\"message\":\"you are not authorized\"}}\n",
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Length": {"59"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 13:44:26 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unauthenticated request",
|
||||
id: id1,
|
||||
params: nil,
|
||||
interceptor: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedBody: "{\"error\":{\"data\":null,\"message\":\"you are not authorized\"}}\n",
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Length": {"59"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 13:44:26 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
Range: ptr("bytes=0-4"),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPartialContent,
|
||||
expectedBody: "Hello",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"5"},
|
||||
"Content-Range": []string{"bytes 0-4/13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range middle",
|
||||
id: id1,
|
||||
params: &client.GetFileParams{
|
||||
Range: ptr("bytes=2-8"),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPartialContent,
|
||||
expectedBody: "llo, Wo",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"7"},
|
||||
"Content-Range": []string{"bytes 2-8/13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
id: id2,
|
||||
params: nil,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "ignoreme",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="nhost.jpg"`},
|
||||
"Content-Length": []string{"33399"},
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"78b676e65ebc31f0bb1f2f0d05098572"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{id2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image manipulation",
|
||||
id: id2,
|
||||
params: &client.GetFileParams{
|
||||
Q: ptr(80),
|
||||
H: ptr(100),
|
||||
W: ptr(100),
|
||||
B: ptr(float32(0.10)),
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "ignoreme",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=3600"},
|
||||
"Content-Disposition": []string{`inline; filename="nhost.jpg"`},
|
||||
"Content-Length": []string{"8709"},
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"78b676e65ebc31f0bb1f2f0d05098572"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=3600"},
|
||||
"Surrogate-Key": []string{id2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cl.GetFileWithResponse(
|
||||
t.Context(),
|
||||
tc.id,
|
||||
tc.params,
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
if tc.expectedBody != "ignoreme" {
|
||||
if diff := cmp.Diff(string(resp.Body), tc.expectedBody); diff != "" {
|
||||
t.Errorf("unexpected response body: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeaders,
|
||||
IgnoreResponseHeaders(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected response headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
532
services/storage/client/get_file_with_presigned_url_test.go
Normal file
532
services/storage/client/get_file_with_presigned_url_test.go
Normal file
@@ -0,0 +1,532 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func compareCacheControlMaxAge() cmp.Option { //nolint:cyclop
|
||||
return cmp.FilterPath(
|
||||
func(p cmp.Path) bool {
|
||||
return p.Last().String() == `["Cache-Control"]` ||
|
||||
p.Last().String() == `["Surrogate-Control"]`
|
||||
},
|
||||
cmp.Comparer(func(a, b []string) bool {
|
||||
if len(a) != 1 || len(b) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Accept max-age values from 27 to 30
|
||||
validValues := []string{"max-age=27", "max-age=28", "max-age=29", "max-age=30"}
|
||||
for _, valid := range validValues {
|
||||
if a[0] == valid || b[0] == valid {
|
||||
for _, otherValid := range validValues {
|
||||
if (a[0] == valid && b[0] == otherValid) ||
|
||||
(a[0] == otherValid && b[0] == valid) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a[0] == b[0]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestGetFileWithPresignedURL(t *testing.T) { //nolint:cyclop,maintidx
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
uploadInitialFile(t, cl, id1, id2)
|
||||
|
||||
p1, err := cl.GetFilePresignedURLWithResponse(
|
||||
t.Context(), id1, WithAccessToken(accessTokenValidUser),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
presignedURL1, err := url.Parse(p1.JSON200.Url)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse presigned URL: %v", err)
|
||||
}
|
||||
|
||||
params1 := func() *client.GetFileWithPresignedURLParams {
|
||||
return &client.GetFileWithPresignedURLParams{
|
||||
XAmzAlgorithm: presignedURL1.Query().Get("X-Amz-Algorithm"),
|
||||
XAmzChecksumMode: presignedURL1.Query().Get("X-Amz-Checksum-Mode"),
|
||||
XAmzCredential: presignedURL1.Query().Get("X-Amz-Credential"),
|
||||
XAmzDate: presignedURL1.Query().Get("X-Amz-Date"),
|
||||
XAmzExpires: presignedURL1.Query().Get("X-Amz-Expires"),
|
||||
XId: presignedURL1.Query().Get("x-id"),
|
||||
XAmzSignature: presignedURL1.Query().Get("X-Amz-Signature"),
|
||||
XAmzSignedHeaders: presignedURL1.Query().Get("X-Amz-SignedHeaders"),
|
||||
}
|
||||
}
|
||||
|
||||
p2, err := cl.GetFilePresignedURLWithResponse(
|
||||
t.Context(), id2, WithAccessToken(accessTokenValidUser),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
presignedURL2, err := url.Parse(p2.JSON200.Url)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse presigned URL: %v", err)
|
||||
}
|
||||
|
||||
params2 := func() *client.GetFileWithPresignedURLParams {
|
||||
return &client.GetFileWithPresignedURLParams{
|
||||
XAmzAlgorithm: presignedURL2.Query().Get("X-Amz-Algorithm"),
|
||||
XAmzChecksumMode: presignedURL2.Query().Get("X-Amz-Checksum-Mode"),
|
||||
XAmzCredential: presignedURL2.Query().Get("X-Amz-Credential"),
|
||||
XAmzDate: presignedURL2.Query().Get("X-Amz-Date"),
|
||||
XAmzExpires: presignedURL2.Query().Get("X-Amz-Expires"),
|
||||
XId: presignedURL2.Query().Get("x-id"),
|
||||
XAmzSignature: presignedURL2.Query().Get("X-Amz-Signature"),
|
||||
XAmzSignedHeaders: presignedURL2.Query().Get("X-Amz-SignedHeaders"),
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
requestParams func() *client.GetFileWithPresignedURLParams
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expectedStatusCode int
|
||||
expectedBody string
|
||||
expectedErr *client.ErrorResponse
|
||||
expectedHeaders http.Header
|
||||
}{
|
||||
{
|
||||
name: "simple get",
|
||||
id: id1,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
return params1()
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfMatch matches",
|
||||
id: id1,
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfMatch = ptr(`"65a8e27d8879283831b664bd8b7f0ad4"`)
|
||||
return req
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfMatch does not match",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfMatch = ptr(`"85a8e27d8879283831b664bd8b7f0ad4"`)
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPreconditionFailed,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Length": []string{"0"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfNoneMatch matches",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfNoneMatch = ptr(`"65a8e27d8879283831b664bd8b7f0ad4"`)
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotModified,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfNoneMatch does not match",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfNoneMatch = ptr(`"85a8e27d8879283831b664bd8b7f0ad4"`)
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfModifiedSince matches",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfModifiedSince = ptr(client.NewTime(time.Now().Add(-time.Hour)))
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfModifiedSince does not match",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfModifiedSince = ptr(client.NewTime(time.Now().Add(time.Hour)))
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusNotModified,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfUnmodifiedSince matches",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfUnmodifiedSince = ptr(client.NewTime(time.Now().Add(-time.Hour)))
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPreconditionFailed,
|
||||
expectedBody: "",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Length": []string{"0"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IfUnmodifiedSince does not match",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.IfUnmodifiedSince = ptr(client.NewTime(time.Now().Add(time.Hour)))
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-admin-secret",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
return req
|
||||
},
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "x-hasura-role",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
return req
|
||||
},
|
||||
interceptor: WithHeaders(http.Header{
|
||||
"x-hasura-admin-secret": []string{"nhost-admin-secret"},
|
||||
"x-hasura-role": []string{"user"},
|
||||
}),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unauthenticated request",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
return params1()
|
||||
},
|
||||
interceptor: nil,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "Hello, World!",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.Range = ptr("bytes=0-4")
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPartialContent,
|
||||
expectedBody: "Hello",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"5"},
|
||||
"Content-Range": []string{"bytes 0-4/13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range middle",
|
||||
id: id1,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params1()
|
||||
req.Range = ptr("bytes=2-8")
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusPartialContent,
|
||||
expectedBody: "llo, Wo",
|
||||
expectedHeaders: http.Header{
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="testfile.txt"`},
|
||||
"Content-Length": []string{"7"},
|
||||
"Content-Range": []string{"bytes 2-8/13"},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"65a8e27d8879283831b664bd8b7f0ad4"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{"d505075a-ee28-4a02-b27a-5973fd2ea35f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
id: id2,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
return params2()
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "ignoreme",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=29"},
|
||||
"Content-Disposition": []string{`inline; filename="nhost.jpg"`},
|
||||
"Content-Length": []string{"33399"},
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"78b676e65ebc31f0bb1f2f0d05098572"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=29"},
|
||||
"Surrogate-Key": []string{id2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image manipulation",
|
||||
id: id2,
|
||||
requestParams: func() *client.GetFileWithPresignedURLParams {
|
||||
req := params2()
|
||||
req.Q = ptr(80)
|
||||
req.H = ptr(100)
|
||||
req.W = ptr(100)
|
||||
req.B = ptr(float32(0.10))
|
||||
return req
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedBody: "ignoreme",
|
||||
expectedHeaders: http.Header{
|
||||
"Accept-Ranges": []string{"bytes"},
|
||||
"Cache-Control": []string{"max-age=30"},
|
||||
"Content-Disposition": []string{`inline; filename="nhost.jpg"`},
|
||||
"Content-Length": []string{"8709"},
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Date": []string{"Mon, 21 Jul 2025 13:24:53 GMT"},
|
||||
"Etag": []string{`"78b676e65ebc31f0bb1f2f0d05098572"`},
|
||||
"Last-Modified": []string{"2025-07-21 13:24:53.586273 +0000 +0000"},
|
||||
"Surrogate-Control": []string{"max-age=30"},
|
||||
"Surrogate-Key": []string{id2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := cl.GetFileWithPresignedURLWithResponse(
|
||||
t.Context(),
|
||||
tc.id,
|
||||
tc.requestParams(),
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
if tc.expectedBody != "ignoreme" {
|
||||
if diff := cmp.Diff(string(resp.Body), tc.expectedBody); diff != "" {
|
||||
t.Errorf("unexpected response body: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeaders,
|
||||
IgnoreResponseHeaders(),
|
||||
compareCacheControlMaxAge(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected response headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
119
services/storage/client/multipart.go
Normal file
119
services/storage/client/multipart.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func WithMetadata(metadata UploadFileMetadata) func(file *File) {
|
||||
return func(file *File) {
|
||||
file.md = &metadata
|
||||
}
|
||||
}
|
||||
|
||||
type File struct {
|
||||
r io.ReadSeeker
|
||||
name string
|
||||
md *UploadFileMetadata
|
||||
}
|
||||
|
||||
func NewFile(name string, r io.ReadSeeker, metadata *UploadFileMetadata) *File {
|
||||
return &File{
|
||||
r: r,
|
||||
name: name,
|
||||
md: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func createMultiForm(
|
||||
writer *multipart.Writer,
|
||||
fieldName string,
|
||||
file *File,
|
||||
multiple bool,
|
||||
) error {
|
||||
formWriter, err := writer.CreateFormFile(fieldName, file.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem create part: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(formWriter, file.r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem copying file into the form: %w", err)
|
||||
}
|
||||
|
||||
if file.md != nil { //nolint:nestif
|
||||
h := make(textproto.MIMEHeader)
|
||||
if multiple {
|
||||
h.Set("Content-Disposition", `form-data; name="metadata[]"`)
|
||||
} else {
|
||||
h.Set("Content-Disposition", `form-data; name="metadata"`)
|
||||
}
|
||||
|
||||
h.Set("Content-Type", "application/json")
|
||||
|
||||
formWriter, err = writer.CreatePart(h)
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem creating part for metadata: %w", err)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(file.md)
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem marshaling metadata: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(formWriter, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem copying metadata into the form: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateUploadMultiForm(
|
||||
bucketID string,
|
||||
files ...*File,
|
||||
) (io.Reader, string, error) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
formWriter, err := writer.CreateFormField("bucket-id")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("problem creating form field for bucket-id: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(formWriter, strings.NewReader(bucketID))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("problem writing bucket-id: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if err := createMultiForm(writer, "file[]", file, true); err != nil {
|
||||
return nil, "", fmt.Errorf("problem creating form for file %s: %w", file.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
|
||||
return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
func CreateUpdateMultiForm(
|
||||
file *File,
|
||||
) (io.Reader, string, error) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
if err := createMultiForm(writer, "file", file, false); err != nil {
|
||||
return nil, "", fmt.Errorf("problem creating form for file %s: %w", file.name, err)
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
|
||||
return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), nil
|
||||
}
|
||||
291
services/storage/client/replace_file_test.go
Normal file
291
services/storage/client/replace_file_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func TestReplaceFile(t *testing.T) { //nolint:cyclop,maintidx,gocognit
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
uploadInitialFile(t, cl, id1, id2)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
requestBody func(t *testing.T) (io.Reader, string)
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expected *client.FileMetadata
|
||||
expectedStatusCode int
|
||||
expectedHeader http.Header
|
||||
expectedCmpOpts []cmp.Option
|
||||
expectedErr *client.ErrorResponse
|
||||
}{
|
||||
{
|
||||
name: "simple upload",
|
||||
id: id1,
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.OpenFile("testdata/nhost.jpg", os.O_RDONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
body, contentType, err := client.CreateUpdateMultiForm(
|
||||
client.NewFile("nhost.jpg", f, nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expected: &client.FileMetadata{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"78b676e65ebc31f0bb1f2f0d05098572"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "nhost.jpg",
|
||||
MimeType: "image/jpeg",
|
||||
Size: 33399,
|
||||
},
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"287"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "simple upload",
|
||||
id: id1,
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUpdateMultiForm(
|
||||
client.NewFile("changedfile.txt", strings.NewReader("Bye, World!"), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expected: &client.FileMetadata{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"c247b0271b96b82c23c3acd525f9d159"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "changedfile.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 11,
|
||||
},
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"305"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "with virus",
|
||||
id: id1,
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUpdateMultiForm(
|
||||
client.NewFile("blah.txt", strings.NewReader(eicarTestFile), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"116"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"virus found: Win.Test.EICAR_HDB-1"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: &map[string]any{"file": "blah.txt", "virus": "Win.Test.EICAR_HDB-1"},
|
||||
Message: "virus found: Win.Test.EICAR_HDB-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown file id",
|
||||
id: uuid.NewString(),
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUpdateMultiForm(
|
||||
client.NewFile("blah.txt", strings.NewReader("asd"), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expected: nil,
|
||||
expectedStatusCode: http.StatusNotFound,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"51"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"file not found"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: nil,
|
||||
Message: "file not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no permission",
|
||||
id: uuid.NewString(),
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUpdateMultiForm(
|
||||
client.NewFile("blah.txt", strings.NewReader("asd"), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: nil,
|
||||
expected: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"59"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
"X-Error": {"you are not authorized"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponse{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: nil,
|
||||
Message: "you are not authorized",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
body, contentType := tc.requestBody(t)
|
||||
|
||||
resp, err := cl.ReplaceFileWithBodyWithResponse(
|
||||
t.Context(),
|
||||
tc.id,
|
||||
contentType,
|
||||
body,
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upload files: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
opts := append(
|
||||
cmp.Options{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "CreatedAt", "UpdatedAt"),
|
||||
},
|
||||
tc.expectedCmpOpts...,
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(resp.JSON200, tc.expected, opts...); diff != "" {
|
||||
t.Errorf("unexpected response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(resp.JSONDefault, tc.expectedErr, opts); diff != "" {
|
||||
t.Errorf("unexpected error response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeader,
|
||||
compareContentLength(),
|
||||
IgnoreResponseHeaders(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
services/storage/client/testdata/nhost.jpg
vendored
Normal file
BIN
services/storage/client/testdata/nhost.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
429
services/storage/client/upload_files_test.go
Normal file
429
services/storage/client/upload_files_test.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nhost/nhost/services/storage/client"
|
||||
)
|
||||
|
||||
func compareContentLength() cmp.Option {
|
||||
return cmp.FilterPath(
|
||||
func(p cmp.Path) bool {
|
||||
return p.Last().String() == `["Content-Length"]`
|
||||
},
|
||||
cmp.Comparer(func(a, b []string) bool {
|
||||
if len(a) != 1 || len(b) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
if a[0] == b[0] {
|
||||
return true
|
||||
}
|
||||
|
||||
x, _ := strconv.Atoi(a[0])
|
||||
y, _ := strconv.Atoi(b[0])
|
||||
|
||||
if y-3 <= x && x <= y+3 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestUploadFiles(t *testing.T) { //nolint:cyclop,maintidx,gocognit
|
||||
t.Parallel()
|
||||
|
||||
cl, err := client.NewClientWithResponses(testBaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
id1 := uuid.NewString()
|
||||
id2 := uuid.NewString()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requestBody func(t *testing.T) (io.Reader, string)
|
||||
interceptor func(ctx context.Context, req *http.Request) error
|
||||
expected *struct {
|
||||
ProcessedFiles []client.FileMetadata `json:"processedFiles"`
|
||||
}
|
||||
expectedStatusCode int
|
||||
expectedHeader http.Header
|
||||
expectedCmpOpts []cmp.Option
|
||||
expectedErr *client.ErrorResponseWithProcessedFiles
|
||||
}{
|
||||
{
|
||||
name: "simple upload",
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"default",
|
||||
client.NewFile("testfile.txt", strings.NewReader("Hello, World!"), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusCreated,
|
||||
expected: &struct {
|
||||
ProcessedFiles []client.FileMetadata `json:"processedFiles"`
|
||||
}{
|
||||
ProcessedFiles: []client.FileMetadata{
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"65a8e27d8879283831b664bd8b7f0ad4"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "testfile.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 13,
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"323"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "multi-upload",
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"default",
|
||||
client.NewFile("testfile.txt", strings.NewReader("Hello, World!"), nil),
|
||||
client.NewFile("morefiles.txt", strings.NewReader("More content"), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusCreated,
|
||||
expected: &struct {
|
||||
ProcessedFiles []client.FileMetadata `json:"processedFiles"`
|
||||
}{
|
||||
ProcessedFiles: []client.FileMetadata{
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"65a8e27d8879283831b664bd8b7f0ad4"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "testfile.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 13,
|
||||
},
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"2562b24d9ca6633770dec8cbb190cca8"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "morefiles.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"626"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "with metadata",
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"default",
|
||||
client.NewFile(
|
||||
"testfile.txt",
|
||||
strings.NewReader("Hello, World!"),
|
||||
&client.UploadFileMetadata{
|
||||
Id: ptr(id1),
|
||||
Metadata: ptr(map[string]any{"key": "value"}),
|
||||
Name: ptr("Custom Name.txt"),
|
||||
},
|
||||
),
|
||||
client.NewFile(
|
||||
"morefiles.txt",
|
||||
strings.NewReader("More content"),
|
||||
&client.UploadFileMetadata{
|
||||
Id: ptr(id2),
|
||||
Metadata: nil,
|
||||
Name: nil,
|
||||
},
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expectedStatusCode: http.StatusCreated,
|
||||
expected: &struct {
|
||||
ProcessedFiles []client.FileMetadata `json:"processedFiles"`
|
||||
}{
|
||||
ProcessedFiles: []client.FileMetadata{
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"65a8e27d8879283831b664bd8b7f0ad4"`,
|
||||
Id: id1,
|
||||
IsUploaded: true,
|
||||
Metadata: ptr(map[string]any{"key": "value"}),
|
||||
Name: "Custom Name.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 13,
|
||||
},
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"2562b24d9ca6633770dec8cbb190cca8"`,
|
||||
Id: id2,
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "morefiles.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"640"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong bucket",
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"wrong-bucket",
|
||||
client.NewFile("testfile.txt", strings.NewReader("Hello, World!"), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expected: nil,
|
||||
expectedStatusCode: http.StatusNotFound,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"75"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{},
|
||||
expectedErr: &client.ErrorResponseWithProcessedFiles{
|
||||
ProcessedFiles: nil,
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: nil,
|
||||
Message: "bucket not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with virus",
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"default",
|
||||
client.NewFile("testfile.txt", strings.NewReader("Hello, World!"), nil),
|
||||
client.NewFile("morefiles.txt", strings.NewReader("More content"), nil),
|
||||
client.NewFile("blah.txt", strings.NewReader(eicarTestFile), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: WithAccessToken(accessTokenValidUser),
|
||||
expected: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"740"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponseWithProcessedFiles{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: &map[string]any{"file": "blah.txt", "virus": "Win.Test.EICAR_HDB-1"},
|
||||
Message: "virus found: Win.Test.EICAR_HDB-1",
|
||||
},
|
||||
ProcessedFiles: &[]client.FileMetadata{
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"65a8e27d8879283831b664bd8b7f0ad4"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "testfile.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 13,
|
||||
},
|
||||
{
|
||||
BucketId: "default",
|
||||
CreatedAt: time.Time{},
|
||||
Etag: `"2562b24d9ca6633770dec8cbb190cca8"`,
|
||||
Id: "69045896-4b8e-4bd1-a87b-e1386cb7",
|
||||
IsUploaded: true,
|
||||
Metadata: nil,
|
||||
Name: "morefiles.txt",
|
||||
MimeType: "text/plain; charset=utf-8",
|
||||
Size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
requestBody: func(t *testing.T) (io.Reader, string) {
|
||||
t.Helper()
|
||||
|
||||
body, contentType, err := client.CreateUploadMultiForm(
|
||||
"default",
|
||||
client.NewFile("testfile.txt", strings.NewReader("Hello, World!"), nil),
|
||||
client.NewFile("morefiles.txt", strings.NewReader("More content"), nil),
|
||||
client.NewFile("blah.txt", strings.NewReader(eicarTestFile), nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create upload multi-form: %v", err)
|
||||
}
|
||||
|
||||
return body, contentType
|
||||
},
|
||||
interceptor: nil,
|
||||
expected: nil,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedHeader: http.Header{
|
||||
"Content-Length": {"79"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Date": {"Mon, 21 Jul 2025 14:45:00 GMT"},
|
||||
},
|
||||
expectedCmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "Id"),
|
||||
},
|
||||
expectedErr: &client.ErrorResponseWithProcessedFiles{
|
||||
Error: &struct {
|
||||
Data *map[string]any `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Data: nil,
|
||||
Message: "you are not authorized",
|
||||
},
|
||||
ProcessedFiles: &[]client.FileMetadata{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var interceptor []client.RequestEditorFn
|
||||
if tc.interceptor != nil {
|
||||
interceptor = []client.RequestEditorFn{
|
||||
tc.interceptor,
|
||||
}
|
||||
}
|
||||
|
||||
body, contentType := tc.requestBody(t)
|
||||
|
||||
resp, err := cl.UploadFilesWithBodyWithResponse(
|
||||
t.Context(),
|
||||
contentType,
|
||||
body,
|
||||
interceptor...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to upload files: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != tc.expectedStatusCode {
|
||||
t.Errorf(
|
||||
"expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode(),
|
||||
)
|
||||
}
|
||||
|
||||
opts := append(
|
||||
cmp.Options{
|
||||
cmpopts.IgnoreFields(client.FileMetadata{}, "CreatedAt", "UpdatedAt"),
|
||||
},
|
||||
tc.expectedCmpOpts...,
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(resp.JSON201, tc.expected, opts...); diff != "" {
|
||||
t.Errorf("unexpected response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(resp.JSONDefault, tc.expectedErr, opts); diff != "" {
|
||||
t.Errorf("unexpected error response: %s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(
|
||||
resp.HTTPResponse.Header,
|
||||
tc.expectedHeader,
|
||||
compareContentLength(),
|
||||
IgnoreResponseHeaders(),
|
||||
); diff != "" {
|
||||
t.Errorf("unexpected headers: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
services/storage/cmd/antivirus.go
Normal file
53
services/storage/cmd/antivirus.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/nhost/nhost/services/storage/clamd"
|
||||
"github.com/nhost/nhost/services/storage/controller"
|
||||
)
|
||||
|
||||
type DummyAntivirus struct{}
|
||||
|
||||
func (d *DummyAntivirus) ScanReader(_ context.Context, _ io.ReaderAt) *controller.APIError {
|
||||
return nil
|
||||
}
|
||||
|
||||
type ClamavWrapper struct {
|
||||
clamav *clamd.Client
|
||||
}
|
||||
|
||||
func (c *ClamavWrapper) ScanReader(ctx context.Context, r io.ReaderAt) *controller.APIError {
|
||||
err := c.clamav.InStream(ctx, r)
|
||||
|
||||
virusFoundErr := &clamd.VirusFoundError{} //nolint:exhaustruct
|
||||
switch {
|
||||
case errors.As(err, &virusFoundErr):
|
||||
err := controller.ForbiddenError(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
err.SetData("virus", virusFoundErr.Name)
|
||||
|
||||
return err
|
||||
case err != nil:
|
||||
return controller.InternalServerError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAv(addr string) (controller.Antivirus, error) { //nolint:ireturn
|
||||
if addr == "" {
|
||||
return &DummyAntivirus{}, nil
|
||||
}
|
||||
|
||||
c, err := clamd.NewClient(addr)
|
||||
if err != nil {
|
||||
return nil, controller.InternalServerError(err)
|
||||
}
|
||||
|
||||
return &ClamavWrapper{clamav: c}, nil
|
||||
}
|
||||
36
services/storage/cmd/flags.go
Normal file
36
services/storage/cmd/flags.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func addBoolFlag(
|
||||
flags *pflag.FlagSet,
|
||||
name string,
|
||||
defaultValue bool, //nolint:unparam
|
||||
help string,
|
||||
) {
|
||||
flags.Bool(name, defaultValue, help)
|
||||
|
||||
if err := viper.BindPFlag(name, flags.Lookup(name)); err != nil {
|
||||
cobra.CheckErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addStringFlag(flags *pflag.FlagSet, name string, defaultValue string, help string) {
|
||||
flags.String(name, defaultValue, help)
|
||||
|
||||
if err := viper.BindPFlag(name, flags.Lookup(name)); err != nil {
|
||||
cobra.CheckErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addStringArrayFlag(flags *pflag.FlagSet, name string, defaultValue []string, help string) {
|
||||
flags.StringArray(name, defaultValue, help)
|
||||
|
||||
if err := viper.BindPFlag(name, flags.Lookup(name)); err != nil {
|
||||
cobra.CheckErr(err)
|
||||
}
|
||||
}
|
||||
12
services/storage/cmd/logger.go
Normal file
12
services/storage/cmd/logger.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
func getLogger() *logrus.Logger {
|
||||
logger := logrus.New()
|
||||
logger.SetFormatter(&logrus.TextFormatter{ //nolint:exhaustruct
|
||||
FullTimestamp: true,
|
||||
})
|
||||
|
||||
return logger
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user