Слеш внутри ID Go API: как сохранить один сегмент URL

Хранилище выдаёт ключ invoice/2026, а клиент должен получить его по endpoint /objects/{key}. Ручная склейка создаёт /objects/invoice/2026: сервер видит два сегмента вместо одного идентификатора. Query-кодирование тоже не подходит, потому что специальные символы пути и параметры запроса имеют разные правила.

Разделить путь и значение сегмента

url.URL хранит Path в декодированном виде. Для сохранения специального представления есть RawPath: это необязательная закодированная форма того же пути. Если положить строку с %2F прямо в Path, при сериализации знак процента может быть закодирован повторно, и сервер получит буквальный текст %2F вместо слеша внутри значения.

В примере базовый путь известен и контролируется приложением. Ключ добавляется к Path как данные, а RawPath строится из того же основания и PathEscape(key). Метод EscapedPath проверяет, соответствует ли RawPath декодированному пути, и использует подходящее представление при формировании запроса. На сервере мы читаем исходную форму RequestURI и декодированный URL.Path: они показывают разные уровни одного запроса.

Такое представление не гарантирует, что любой маршрутизатор пропустит закодированный слеш как один параметр. Прокси и роутеры могут нормализовать путь раньше обработчика. Поэтому нужно проверять весь маршрут конкретного API. Локальный пример фиксирует поведение стандартного клиента и сервера, а контракт внешнего поставщика определяет, разрешён ли подобный ключ вообще.

Проверить RequestURI сервера

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
)

func main() {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.RequestURI != "/objects/invoice%2F2026" {
			panic("encoded segment changed: " + r.RequestURI)
		}
		if r.URL.Path != "/objects/invoice/2026" {
			panic("decoded path changed")
		}
		io.WriteString(w, r.RequestURI)
	}))
	defer server.Close()
	u, err := url.Parse(server.URL)
	if err != nil {
		panic(err)
	}
	key := "invoice/2026"
	u.Path = "/objects/" + key
	u.RawPath = "/objects/" + url.PathEscape(key)
	response, err := server.Client().Get(u.String())
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil || string(body) != "/objects/invoice%2F2026" {
		panic("unexpected request target")
	}
	fmt.Println(string(body))
}

Не применяйте PathEscape ко всему готовому пути: структурные разделители тоже превратятся в данные. Экранируйте именно значение одного сегмента. Если identifier по правилам сервиса может содержать только цифры и буквы, проверка допустимого набора на границе будет проще. Если слеши являются частью иерархического пути, их, наоборот, нужно сохранить как разделители и отдельно решить вопрос сегментов точка и точка-точка.

Проверить посредников и другие символы

url.JoinPath удобен для соединения частей пути, но он очищает структурный путь. Поэтому этот инструмент не заменяет решение, какие символы принадлежат структуре, а какие — одному непрозрачному ID. В статье о query уже разобрана похожая граница для амперсанда и плюса; здесь проверяется другая часть URL и другое поведение сервера.

Добавьте ключ с пробелом, знаком процента и кириллицей. Для каждого значения проверьте исходный RequestURI и ожидаемое декодирование. Не сравнивайте только красивую строку, которую вы напечатали перед отправкой: клиент или посредник может изменить представление. Для подписи запросов используйте ту каноническую форму, которую требует поставщик.

Если этим клиентом управляет пользователь, фиксируйте разрешённые scheme и host отдельно. Корректное представление одного ID не ограничивает выбор сетевого адреса. Здесь сервер задаётся самим тестом, поэтому проверка касается исключительно сохранения значения при переходе в HTTP-путь.

Продолжить практику

Сохранность пути — часть контракта клиента и обработчика. В курсе «Микросервисы на Go» вы проверите HTTP-взаимодействие двух программ и обработку неправильных ответов. Откройте программу и продолжите контрактным тестом, который проверяет не только статус, но и фактически полученный идентификатор.

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.