diff --git a/ansible/vars.yml b/ansible/vars.yml index e7e15a78d..0fd92cab1 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -1,3 +1,4 @@ +--- supabase_internal: true ebssurrogate_mode: true async_mode: true @@ -5,33 +6,33 @@ async_mode: true postgres_major: - "15" - "17" - - "orioledb-17" + - orioledb-17 # Full version strings for each major version postgres_release: - postgresorioledb-17: "17.5.1.029" - postgres17: "17.6.1.008" - postgres15: "15.14.1.008" + postgresorioledb-17: 17.5.1.030-orioledb + postgres17: 17.6.1.009 + postgres15: 15.14.1.009 # Non Postgres Extensions -pgbouncer_release: "1.19.0" +pgbouncer_release: 1.19.0 pgbouncer_release_checksum: sha256:af0b05e97d0e1fd9ad45fe00ea6d2a934c63075f67f7e2ccef2ca59e3d8ce682 # The checksum can be found under "Assets", in the GitHub release page for each version. # The binaries used are: ubuntu-aarch64 and linux-static. # https://github.com/PostgREST/postgrest/releases -postgrest_release: "13.0.5" +postgrest_release: 13.0.5 postgrest_arm_release_checksum: sha256:7b4eafdaf76bc43b57f603109d460a838f89f949adccd02f452ca339f9a0a0d4 postgrest_x86_release_checksum: sha256:05be2bd48abee6c1691fc7c5d005023466c6989e41a4fc7d1302b8212adb88b5 gotrue_release: 2.179.0 gotrue_release_checksum: sha1:e985fce00b2720b747e6a04420910015c4967121 -aws_cli_release: "2.23.11" +aws_cli_release: 2.23.11 salt_minion_version: 3007 -golang_version: "1.22.11" +golang_version: 1.22.11 golang_version_checksum: arm64: sha256:0fc88d966d33896384fbde56e9a8d80a305dc17a9f48f1832e061724b1719991 amd64: sha256:9ebfcab26801fa4cf0627c6439db7a4da4d3c6766142a3dd83508240e4f21031 @@ -52,10 +53,10 @@ postgres_exporter_release_checksum: arm64: sha256:29ba62d538b92d39952afe12ee2e1f4401250d678ff4b354ff2752f4321c87a0 amd64: sha256:cb89fc5bf4485fb554e0d640d9684fae143a4b2d5fa443009bd29c59f9129e84 -adminapi_release: 0.92.1 -adminmgr_release: 0.32.1 +adminapi_release: "0.92.1" +adminmgr_release: "0.32.1" supabase_admin_agent_release: 1.4.38 supabase_admin_agent_splay: 30 -vector_x86_deb: "https://packages.timber.io/vector/0.48.X/vector_0.48.0-1_amd64.deb" -vector_arm_deb: "https://packages.timber.io/vector/0.48.X/vector_0.48.0-1_arm64.deb" +vector_x86_deb: https://packages.timber.io/vector/0.48.X/vector_0.48.0-1_amd64.deb +vector_arm_deb: https://packages.timber.io/vector/0.48.X/vector_0.48.0-1_arm64.deb diff --git a/nix/checks.nix b/nix/checks.nix index d6d3aa59f..74b50a84f 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -163,11 +163,43 @@ which getkey-script supabase-groonga + python3 + netcat ]; } '' set -e + # Start HTTP mock server for http extension tests + # Use a build-specific directory for coordination + BUILD_TMP=$(mktemp -d) + HTTP_MOCK_PORT_FILE="$BUILD_TMP/http-mock-port" + + echo "Starting HTTP mock server (will find free port)..." + HTTP_MOCK_PORT_FILE="$HTTP_MOCK_PORT_FILE" ${pkgs.python3}/bin/python3 ${./tests/http-mock-server.py} & + HTTP_MOCK_PID=$! + + # Clean up on exit + trap "kill $HTTP_MOCK_PID 2>/dev/null || true; rm -rf '$BUILD_TMP'" EXIT + + # Wait for server to start and write port file + for i in {1..10}; do + if [ -f "$HTTP_MOCK_PORT_FILE" ]; then + HTTP_MOCK_PORT=$(cat "$HTTP_MOCK_PORT_FILE") + echo "HTTP mock server started on port $HTTP_MOCK_PORT" + break + fi + sleep 1 + done + + if [ ! -f "$HTTP_MOCK_PORT_FILE" ]; then + echo "Failed to start HTTP mock server" + exit 1 + fi + + # Export the port for use in SQL tests + export HTTP_MOCK_PORT + #First we need to create a generic pg cluster for pgtap tests and run those export GRN_PLUGINS_DIR=${pkgs.supabase-groonga}/lib/groonga/plugins PGTAP_CLUSTER=$(mktemp -d) @@ -228,6 +260,13 @@ pg_ctl -D "$PGTAP_CLUSTER" stop exit 1 fi + + # Create a table to store test configuration + psql -p ${pgPort} -h ${self.supabase.defaults.host} --username=supabase_admin -d testing -c " + CREATE TABLE IF NOT EXISTS test_config (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO test_config (key, value) VALUES ('http_mock_port', '$HTTP_MOCK_PORT') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + " SORTED_DIR=$(mktemp -d) for t in $(printf "%s\n" ${builtins.concatStringsSep " " sortedTestList}); do psql -p ${pgPort} -h ${self.supabase.defaults.host} --username=supabase_admin -d testing -f "${./tests/sql}/$t.sql" || true @@ -261,6 +300,13 @@ exit 1 fi + # Create a table to store test configuration for pg_regress tests + psql -p ${pgPort} -h ${self.supabase.defaults.host} --no-password --username=supabase_admin -d postgres -c " + CREATE TABLE IF NOT EXISTS test_config (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO test_config (key, value) VALUES ('http_mock_port', '$HTTP_MOCK_PORT') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + " + mkdir -p $out/regression_output if ! pg_regress \ --use-existing \ diff --git a/nix/ext/pgsql-http.nix b/nix/ext/pgsql-http.nix index 9f4bae789..6b9394e55 100644 --- a/nix/ext/pgsql-http.nix +++ b/nix/ext/pgsql-http.nix @@ -1,39 +1,109 @@ { + pkgs, lib, stdenv, fetchFromGitHub, - curl, postgresql, + curl, }: +let + pname = "http"; -stdenv.mkDerivation rec { - pname = "pgsql-http"; - version = "1.6.1"; + # Load version configuration from external file + allVersions = (builtins.fromJSON (builtins.readFile ./versions.json)).${pname}; - buildInputs = [ - curl - postgresql - ]; + # Filter versions compatible with current PostgreSQL version + supportedVersions = lib.filterAttrs ( + _: value: builtins.elem (lib.versions.major postgresql.version) value.postgresql + ) allVersions; - src = fetchFromGitHub { - owner = "pramsey"; - repo = pname; - rev = "refs/tags/v${version}"; - hash = "sha256-C8eqi0q1dnshUAZjIsZFwa5FTYc7vmATF3vv2CReWPM="; - }; + # Derived version information + versions = lib.naturalSort (lib.attrNames supportedVersions); + latestVersion = lib.last versions; + numberOfVersions = builtins.length versions; + packages = builtins.attrValues ( + lib.mapAttrs (name: value: build name value.hash) supportedVersions + ); + + # Build function for individual versions + build = + version: hash: + stdenv.mkDerivation rec { + inherit pname version; + + buildInputs = [ + curl + postgresql + ]; + + src = fetchFromGitHub { + owner = "pramsey"; + repo = "pgsql-http"; + rev = "refs/tags/v${version}"; + inherit hash; + }; + + installPhase = '' + runHook preInstall - installPhase = '' - mkdir -p $out/{lib,share/postgresql/extension} + mkdir -p $out/{lib,share/postgresql/extension} + + # Install versioned library + install -Dm755 ${pname}${postgresql.dlSuffix} $out/lib/${pname}--${version}${postgresql.dlSuffix} + + cp ${pname}--${version}.sql $out/share/postgresql/extension/${pname}--${version}.sql + + # Create versioned control file with modified module path + sed -e "/^default_version =/d" \ + -e "s|^module_pathname = .*|module_pathname = '\$libdir/${pname}'|" \ + ${pname}.control > $out/share/postgresql/extension/${pname}--${version}.control + + # For the latest version, create default control file and symlink and copy SQL upgrade scripts + if [[ "${version}" == "${latestVersion}" ]]; then + { + echo "default_version = '${version}'" + cat $out/share/postgresql/extension/${pname}--${version}.control + } > $out/share/postgresql/extension/${pname}.control + ln -sfn ${pname}--${latestVersion}${postgresql.dlSuffix} $out/lib/${pname}${postgresql.dlSuffix} + cp *.sql $out/share/postgresql/extension + fi + + runHook postInstall + ''; + + meta = with lib; { + description = "HTTP client for Postgres"; + homepage = "https://github.com/pramsey/${pname}"; + inherit (postgresql.meta) platforms; + license = licenses.postgresql; + }; + }; +in +pkgs.buildEnv { + name = pname; + paths = packages; + + pathsToLink = [ + "/lib" + "/share/postgresql/extension" + ]; + postBuild = '' + # Verify all expected library files are present + expectedFiles=${toString (numberOfVersions + 1)} + actualFiles=$(ls -A $out/lib/${pname}*${postgresql.dlSuffix} | wc -l) - cp *${postgresql.dlSuffix} $out/lib - cp *.sql $out/share/postgresql/extension - cp *.control $out/share/postgresql/extension + if [[ "$actualFiles" != "$expectedFiles" ]]; then + echo "Error: Expected $expectedFiles library files, found $actualFiles" + echo "Files found:" + ls -la $out/lib/${pname}*${postgresql.dlSuffix} || true + exit 1 + fi ''; - meta = with lib; { - description = "HTTP client for Postgres"; - homepage = "https://github.com/pramsey/${pname}"; - platforms = postgresql.meta.platforms; - license = licenses.postgresql; + passthru = { + inherit versions numberOfVersions; + pname = "${pname}-all"; + version = + "multi-" + lib.concatStringsSep "-" (map (v: lib.replaceStrings [ "." ] [ "-" ] v) versions); }; } diff --git a/nix/ext/tests/http.nix b/nix/ext/tests/http.nix new file mode 100644 index 000000000..100fdcd00 --- /dev/null +++ b/nix/ext/tests/http.nix @@ -0,0 +1,154 @@ +{ self, pkgs }: +let + pname = "http"; + inherit (pkgs) lib; + installedExtension = + postgresMajorVersion: self.packages.${pkgs.system}."psql_${postgresMajorVersion}/exts/${pname}-all"; + versions = postgresqlMajorVersion: (installedExtension postgresqlMajorVersion).versions; + postgresqlWithExtension = + postgresql: + let + majorVersion = lib.versions.major postgresql.version; + pkg = pkgs.buildEnv { + name = "postgresql-${majorVersion}-${pname}"; + paths = [ + postgresql + postgresql.lib + (installedExtension majorVersion) + ]; + passthru = { + inherit (postgresql) version psqlSchema; + lib = pkg; + withPackages = _: pkg; + }; + nativeBuildInputs = [ pkgs.makeWrapper ]; + pathsToLink = [ + "/" + "/bin" + "/lib" + ]; + postBuild = '' + wrapProgram $out/bin/postgres --set NIX_PGLIBDIR $out/lib + wrapProgram $out/bin/pg_ctl --set NIX_PGLIBDIR $out/lib + wrapProgram $out/bin/pg_upgrade --set NIX_PGLIBDIR $out/lib + ''; + }; + in + pkg; +in +self.inputs.nixpkgs.lib.nixos.runTest { + name = pname; + hostPkgs = pkgs; + nodes.server = + { config, ... }: + { + virtualisation = { + forwardPorts = [ + { + from = "host"; + host.port = 13022; + guest.port = 22; + } + ]; + }; + services.openssh = { + enable = true; + }; + + services.postgresql = { + enable = true; + package = postgresqlWithExtension self.packages.${pkgs.system}.postgresql_15; + }; + + specialisation.postgresql17.configuration = { + services.postgresql = { + package = lib.mkForce (postgresqlWithExtension self.packages.${pkgs.system}.postgresql_17); + }; + + systemd.services.postgresql-migrate = { + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "postgres"; + Group = "postgres"; + StateDirectory = "postgresql"; + WorkingDirectory = "${builtins.dirOf config.services.postgresql.dataDir}"; + }; + script = + let + oldPostgresql = postgresqlWithExtension self.packages.${pkgs.system}.postgresql_15; + newPostgresql = postgresqlWithExtension self.packages.${pkgs.system}.postgresql_17; + oldDataDir = "${builtins.dirOf config.services.postgresql.dataDir}/${oldPostgresql.psqlSchema}"; + newDataDir = "${builtins.dirOf config.services.postgresql.dataDir}/${newPostgresql.psqlSchema}"; + in + '' + if [[ ! -d ${newDataDir} ]]; then + install -d -m 0700 -o postgres -g postgres "${newDataDir}" + ${newPostgresql}/bin/initdb -D "${newDataDir}" + ${newPostgresql}/bin/pg_upgrade --old-datadir "${oldDataDir}" --new-datadir "${newDataDir}" \ + --old-bindir "${oldPostgresql}/bin" --new-bindir "${newPostgresql}/bin" + else + echo "${newDataDir} already exists" + fi + ''; + }; + + systemd.services.postgresql = { + after = [ "postgresql-migrate.service" ]; + requires = [ "postgresql-migrate.service" ]; + }; + }; + }; + testScript = + { nodes, ... }: + let + pg17-configuration = "${nodes.server.system.build.toplevel}/specialisation/postgresql17"; + in + '' + versions = { + "15": [${lib.concatStringsSep ", " (map (s: ''"${s}"'') (versions "15"))}], + "17": [${lib.concatStringsSep ", " (map (s: ''"${s}"'') (versions "17"))}], + } + + def run_sql(query): + return server.succeed(f"""sudo -u postgres psql -t -A -F\",\" -c \"{query}\" """).strip() + + def check_upgrade_path(pg_version): + with subtest("Check ${pname} upgrade path"): + firstVersion = versions[pg_version][0] + server.succeed("sudo -u postgres psql -c 'DROP EXTENSION IF EXISTS ${pname};'") + run_sql(f"""CREATE EXTENSION ${pname} WITH VERSION '{firstVersion}' CASCADE;""") + installed_version = run_sql(r"""SELECT extversion FROM pg_extension WHERE extname = '${pname}';""") + assert installed_version == firstVersion, f"Expected ${pname} version {firstVersion}, but found {installed_version}" + for version in versions[pg_version][1:]: + run_sql(f"""ALTER EXTENSION ${pname} UPDATE TO '{version}';""") + installed_version = run_sql(r"""SELECT extversion FROM pg_extension WHERE extname = '${pname}';""") + assert installed_version == version, f"Expected ${pname} version {version}, but found {installed_version}" + + start_all() + + server.wait_for_unit("multi-user.target") + server.wait_for_unit("postgresql.service") + + check_upgrade_path("15") + + with subtest("Check ${pname} latest extension version"): + server.succeed("sudo -u postgres psql -c 'DROP EXTENSION ${pname};'") + server.succeed("sudo -u postgres psql -c 'CREATE EXTENSION ${pname} CASCADE;'") + installed_extensions=run_sql(r"""SELECT extname, extversion FROM pg_extension;""") + latestVersion = versions["15"][-1] + assert f"${pname},{latestVersion}" in installed_extensions + + with subtest("switch to postgresql 17"): + server.succeed( + "${pg17-configuration}/bin/switch-to-configuration test >&2" + ) + + with subtest("Check ${pname} latest extension version after upgrade"): + installed_extensions=run_sql(r"""SELECT extname, extversion FROM pg_extension;""") + latestVersion = versions["17"][-1] + assert f"${pname},{latestVersion}" in installed_extensions + + check_upgrade_path("17") + ''; +} diff --git a/nix/ext/versions.json b/nix/ext/versions.json index 7fc75a7f8..53666e1cd 100644 --- a/nix/ext/versions.json +++ b/nix/ext/versions.json @@ -1,12 +1,17 @@ { - "index_advisor": { - "0.2.0": { + "http": { + "1.5": { + "postgresql": [ + "15" + ], + "hash": "sha256-+N/CXm4arRgvhglanfvO0FNOBUWV5RL8mn/9FpNvcjY=" + }, + "1.6": { "postgresql": [ "15", - "17", - "orioledb-17" + "17" ], - "hash": "sha256-G0eQk2bY5CNPMeokN/nb05g03CuiplRf902YXFVQFbs=" + "hash": "sha256-C8eqi0q1dnshUAZjIsZFwa5FTYc7vmATF3vv2CReWPM=" } }, "hypopg": { @@ -24,6 +29,16 @@ "hash": "sha256-88uKPSnITRZ2VkelI56jZ9GWazG/Rn39QlyHKJKSKMM=" } }, + "index_advisor": { + "0.2.0": { + "postgresql": [ + "15", + "17", + "orioledb-17" + ], + "hash": "sha256-G0eQk2bY5CNPMeokN/nb05g03CuiplRf902YXFVQFbs=" + } + }, "pg_cron": { "1.3.1": { "postgresql": [ diff --git a/nix/packages/default.nix b/nix/packages/default.nix index fca05a10a..f297c8359 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -35,6 +35,7 @@ dbmate-tool = pkgs.callPackage ./dbmate-tool.nix { inherit (self.supabase) defaults; }; docs = pkgs.callPackage ./docs.nix { }; supabase-groonga = pkgs.callPackage ./groonga { }; + http-mock-server = pkgs.callPackage ./http-mock-server.nix { }; local-infra-bootstrap = pkgs.callPackage ./local-infra-bootstrap.nix { }; migrate-tool = pkgs.callPackage ./migrate-tool.nix { psql_15 = self'.packages."psql_15/bin"; }; overlayfs-on-package = pkgs.callPackage ./overlayfs-on-package.nix { }; diff --git a/nix/packages/http-mock-server.nix b/nix/packages/http-mock-server.nix new file mode 100644 index 000000000..67a4af520 --- /dev/null +++ b/nix/packages/http-mock-server.nix @@ -0,0 +1,35 @@ +{ + pkgs, + lib, + stdenv, +}: + +stdenv.mkDerivation { + pname = "http-mock-server"; + version = "1.0.0"; + + src = ../tests/http-mock-server.py; + + nativeBuildInputs = with pkgs; [ + python3 + makeWrapper + ]; + + dontUnpack = true; + + installPhase = '' + mkdir -p $out/bin + cp $src $out/bin/http-mock-server.py + chmod +x $out/bin/http-mock-server.py + + # Create a wrapper script + makeWrapper ${pkgs.python3}/bin/python3 $out/bin/http-mock-server \ + --add-flags "$out/bin/http-mock-server.py" + ''; + + meta = with lib; { + description = "Simple HTTP mock server for testing"; + license = licenses.mit; + platforms = platforms.all; + }; +} diff --git a/nix/tests/expected/http.out b/nix/tests/expected/http.out new file mode 100644 index 000000000..d83488006 --- /dev/null +++ b/nix/tests/expected/http.out @@ -0,0 +1,105 @@ +-- Test for http extension +-- Basic HTTP functionality tests +-- Test basic HTTP GET request +SELECT status FROM http_get('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/get'); + status +-------- + 200 +(1 row) + +-- Test HTTP GET with headers +SELECT status, content_type +FROM http(( + 'GET', + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/headers', + ARRAY[http_header('User-Agent', 'pg_http_test')], + NULL, + NULL +)::http_request); + status | content_type +--------+--------------------------------- + 200 | application/json; charset=utf-8 +(1 row) + +-- Test HTTP POST request with JSON body +SELECT status FROM http_post( + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/post', + '{"test": "data"}', + 'application/json' +); + status +-------- + 200 +(1 row) + +-- Test HTTP PUT request +SELECT status FROM http_put( + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/put', + '{"update": "data"}', + 'application/json' +); + status +-------- + 200 +(1 row) + +-- Test HTTP DELETE request +SELECT status FROM http_delete('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/delete'); + status +-------- + 200 +(1 row) + +-- Test HTTP PATCH request +SELECT status FROM http_patch( + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/patch', + '{"patch": "data"}', + 'application/json' +); + status +-------- + 200 +(1 row) + +-- Test HTTP HEAD request +SELECT status FROM http_head('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/get'); + status +-------- + 200 +(1 row) + +-- Test response headers parsing +WITH response AS ( + SELECT * FROM http_get('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/response-headers?Content-Type=text/plain') +) +SELECT + status, + content_type, + headers IS NOT NULL as has_headers +FROM response; + status | content_type | has_headers +--------+--------------+------------- + 200 | text/plain | t +(1 row) + +-- Test timeout handling (using a delay endpoint) +-- This should complete successfully with reasonable timeout +SELECT status FROM http(( + 'GET', + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/delay/1', + ARRAY[]::http_header[], + 'application/json', + 2000 -- 2 second timeout +)::http_request); + status +-------- + 200 +(1 row) + +-- Test URL encoding +SELECT status FROM http_get('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/anything?param=value%20with%20spaces&another=123'); + status +-------- + 200 +(1 row) + diff --git a/nix/tests/expected/z_17_rum.out b/nix/tests/expected/z_17_rum.out new file mode 100644 index 000000000..1296befa7 --- /dev/null +++ b/nix/tests/expected/z_17_rum.out @@ -0,0 +1,41 @@ +/* +This extension is excluded from oriole-17 because it uses an unsupported index type +*/ +create schema v; +create table v.test_rum( + t text, + a tsvector +); +create trigger tsvectorupdate + before update or insert on v.test_rum + for each row + execute procedure + tsvector_update_trigger( + 'a', + 'pg_catalog.english', + 't' + ); +insert into v.test_rum(t) +values + ('the situation is most beautiful'), + ('it is a beautiful'), + ('it looks like a beautiful place'); +create index rumidx on v.test_rum using rum (a rum_tsvector_ops); +select + t, + round(a <=> to_tsquery('english', 'beautiful | place')) as rank +from + v.test_rum +where + a @@ to_tsquery('english', 'beautiful | place') +order by + a <=> to_tsquery('english', 'beautiful | place'); + t | rank +---------------------------------+------ + it looks like a beautiful place | 8 + the situation is most beautiful | 16 + it is a beautiful | 16 +(3 rows) + +drop schema v cascade; +NOTICE: drop cascades to table v.test_rum diff --git a/nix/tests/http-mock-server.py b/nix/tests/http-mock-server.py new file mode 100644 index 000000000..fedeb40ad --- /dev/null +++ b/nix/tests/http-mock-server.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Simple HTTP mock server for testing pg_http extension offline. +Mimics basic endpoints similar to httpbingo/postman-echo services. +""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +import json +import urllib.parse +import time + + +class MockHTTPHandler(BaseHTTPRequestHandler): + def _send_json_response(self, status_code=200, data=None): + """Send a JSON response""" + self.send_response(status_code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + response_data = data or {} + self.wfile.write(json.dumps(response_data).encode("utf-8")) + + def _send_text_response( + self, status_code=200, content="", content_type="text/plain" + ): + """Send a text response""" + self.send_response(status_code) + self.send_header("Content-Type", content_type) + self.end_headers() + self.wfile.write(content.encode("utf-8")) + + def _get_request_info(self): + """Get request information""" + parsed_path = urllib.parse.urlparse(self.path) + query_params = urllib.parse.parse_qs(parsed_path.query) + + # Read body if present + content_length = int(self.headers.get("Content-Length", 0)) + body = ( + self.rfile.read(content_length).decode("utf-8") + if content_length > 0 + else "" + ) + + return { + "method": self.command, + "url": self.path, + "path": parsed_path.path, + "query": query_params, + "headers": dict(self.headers), + "body": body, + } + + def do_GET(self): + """Handle GET requests""" + request_info = self._get_request_info() + path = request_info["path"] + + if path == "/get": + response = { + "args": request_info["query"], + "headers": request_info["headers"], + "url": f"http://{self.headers.get('Host', 'localhost:8080')}{self.path}", + } + self._send_json_response(200, response) + + elif path == "/headers": + response = {"headers": request_info["headers"]} + self._send_json_response(200, response) + + elif path == "/response-headers": + # Check if Content-Type is specified in query params + query_params = request_info["query"] + if "Content-Type" in query_params: + content_type = query_params["Content-Type"][0] + self._send_text_response( + 200, "Response with custom content type", content_type + ) + else: + self._send_json_response(200, {"message": "response-headers endpoint"}) + + elif path.startswith("/delay/"): + # Extract delay time from path + try: + delay = int(path.split("/delay/")[1]) + time.sleep(min(delay, 5)) # Cap at 5 seconds + self._send_json_response(200, {"delay": delay}) + except (ValueError, IndexError): + self._send_json_response(400, {"error": "Invalid delay value"}) + + elif path == "/anything" or path.startswith("/anything"): + response = { + "method": "GET", + "args": request_info["query"], + "headers": request_info["headers"], + "url": f"http://{self.headers.get('Host', 'localhost:8080')}{self.path}", + "data": "", + "json": None, + } + self._send_json_response(200, response) + + else: + self._send_json_response(404, {"error": "Not found"}) + + def do_POST(self): + """Handle POST requests""" + request_info = self._get_request_info() + path = request_info["path"] + + if path == "/post": + response = { + "args": request_info["query"], + "data": request_info["body"], + "headers": request_info["headers"], + "json": None, + "url": f"http://{self.headers.get('Host', 'localhost:8080')}{self.path}", + } + + # Try to parse JSON if content-type is json + if "application/json" in request_info["headers"].get("Content-Type", ""): + try: + response["json"] = json.loads(request_info["body"]) + except json.JSONDecodeError: + pass + + self._send_json_response(200, response) + else: + self._send_json_response(404, {"error": "Not found"}) + + def do_PUT(self): + """Handle PUT requests""" + request_info = self._get_request_info() + path = request_info["path"] + + if path == "/put": + response = { + "args": request_info["query"], + "data": request_info["body"], + "headers": request_info["headers"], + "json": None, + "url": f"http://{self.headers.get('Host', 'localhost:8080')}{self.path}", + } + + # Try to parse JSON if content-type is json + if "application/json" in request_info["headers"].get("Content-Type", ""): + try: + response["json"] = json.loads(request_info["body"]) + except json.JSONDecodeError: + pass + + self._send_json_response(200, response) + else: + self._send_json_response(404, {"error": "Not found"}) + + def do_DELETE(self): + """Handle DELETE requests""" + request_info = self._get_request_info() + path = request_info["path"] + + if path == "/delete": + response = { + "args": request_info["query"], + "headers": request_info["headers"], + "url": f"http://{self.headers.get('Host', 'localhost:8080')}{self.path}", + } + self._send_json_response(200, response) + else: + self._send_json_response(404, {"error": "Not found"}) + + def do_PATCH(self): + """Handle PATCH requests""" + request_info = self._get_request_info() + path = request_info["path"] + + if path == "/patch": + response = { + "args": request_info["query"], + "data": request_info["body"], + "headers": request_info["headers"], + "json": None, + "url": f"http://{self.headers.get('Host', 'localhost:8080')}{self.path}", + } + + # Try to parse JSON if content-type is json + if "application/json" in request_info["headers"].get("Content-Type", ""): + try: + response["json"] = json.loads(request_info["body"]) + except json.JSONDecodeError: + pass + + self._send_json_response(200, response) + else: + self._send_json_response(404, {"error": "Not found"}) + + def do_HEAD(self): + """Handle HEAD requests""" + path = urllib.parse.urlparse(self.path).path + + if path == "/get": + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + """Suppress default logging""" + pass + + +def find_free_port(start_port=8880, end_port=8899): + """Find a free port within the given range""" + import socket + + for port in range(start_port, end_port + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("0.0.0.0", port)) + return port + except OSError: + continue + + raise RuntimeError(f"No free port found in range {start_port}-{end_port}") + + +def run_server(port=None): + """Run the mock HTTP server""" + if port is None: + port = find_free_port() + + try: + server = HTTPServer(("0.0.0.0", port), MockHTTPHandler) + print(f"Mock HTTP server running on port {port}") + + # Write port to a file that can be read by the test environment + import os + + port_file = os.environ.get("HTTP_MOCK_PORT_FILE", "/tmp/http-mock-port") + try: + with open(port_file, "w") as f: + f.write(str(port)) + except: + pass # Ignore if we can't write the port file + + server.serve_forever() + except OSError as e: + if port is not None: + # If specific port was requested but failed, try to find a free one + print(f"Port {port} not available, finding free port...") + run_server(None) + else: + raise e + + +if __name__ == "__main__": + import sys + + port = int(sys.argv[1]) if len(sys.argv) > 1 else None + run_server(port) diff --git a/nix/tests/sql/http.sql b/nix/tests/sql/http.sql new file mode 100644 index 000000000..df80feb52 --- /dev/null +++ b/nix/tests/sql/http.sql @@ -0,0 +1,65 @@ +-- Test for http extension +-- Basic HTTP functionality tests + +-- Test basic HTTP GET request +SELECT status FROM http_get('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/get'); + +-- Test HTTP GET with headers +SELECT status, content_type +FROM http(( + 'GET', + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/headers', + ARRAY[http_header('User-Agent', 'pg_http_test')], + NULL, + NULL +)::http_request); + +-- Test HTTP POST request with JSON body +SELECT status FROM http_post( + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/post', + '{"test": "data"}', + 'application/json' +); + +-- Test HTTP PUT request +SELECT status FROM http_put( + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/put', + '{"update": "data"}', + 'application/json' +); + +-- Test HTTP DELETE request +SELECT status FROM http_delete('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/delete'); + +-- Test HTTP PATCH request +SELECT status FROM http_patch( + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/patch', + '{"patch": "data"}', + 'application/json' +); + +-- Test HTTP HEAD request +SELECT status FROM http_head('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/get'); + +-- Test response headers parsing +WITH response AS ( + SELECT * FROM http_get('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/response-headers?Content-Type=text/plain') +) +SELECT + status, + content_type, + headers IS NOT NULL as has_headers +FROM response; + +-- Test timeout handling (using a delay endpoint) +-- This should complete successfully with reasonable timeout +SELECT status FROM http(( + 'GET', + 'http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/delay/1', + ARRAY[]::http_header[], + 'application/json', + 2000 -- 2 second timeout +)::http_request); + +-- Test URL encoding +SELECT status FROM http_get('http://localhost:' || (SELECT value FROM test_config WHERE key = 'http_mock_port') || '/anything?param=value%20with%20spaces&another=123'); diff --git a/nix/tests/sql/z_17_rum.sql b/nix/tests/sql/z_17_rum.sql new file mode 100644 index 000000000..6ae945975 --- /dev/null +++ b/nix/tests/sql/z_17_rum.sql @@ -0,0 +1,40 @@ +/* +This extension is excluded from oriole-17 because it uses an unsupported index type +*/ +create schema v; + +create table v.test_rum( + t text, + a tsvector +); + +create trigger tsvectorupdate + before update or insert on v.test_rum + for each row + execute procedure + tsvector_update_trigger( + 'a', + 'pg_catalog.english', + 't' + ); + +insert into v.test_rum(t) +values + ('the situation is most beautiful'), + ('it is a beautiful'), + ('it looks like a beautiful place'); + +create index rumidx on v.test_rum using rum (a rum_tsvector_ops); + +select + t, + round(a <=> to_tsquery('english', 'beautiful | place')) as rank +from + v.test_rum +where + a @@ to_tsquery('english', 'beautiful | place') +order by + a <=> to_tsquery('english', 'beautiful | place'); + + +drop schema v cascade;