
Всем привет! На связи Сергей, ведущий фронтенд-разработчик в команде привлечения Центрального университета. Команда привлечения создает цифровые сервисы для студентов и абитуриентов, такие как мероприятия и анкеты поступления в личном кабинете, телеграм-боты и другое.
Расскажу историю, как валидировал ссылку и попал впросак. Я использовал браузерный URL API для своих целей и думал, что знаю, как он работает. И прежде чем кидать в меня помидоры, как в разработчика, который не читает документацию, дайте шанс рассказать свою историю.
Пять способов валидации ссылки
Представим форму обратной связи, в которой одно из полей — поле для ввода ссылки. Например, в этом поле нужно указать личный сайт или ссылку на репозиторий. Задача — проверить валидность ссылки.
Я заранее прикинул, какими способами можно было бы провалидировать ссылку.
Взять поле с типом URL, который должен проверить ссылки с помощью встроенной HTML-валидации. Встроенная валидация используется далеко не везде, часто это связано с тем, что нужно задать дополнительно правила и единообразно стилизовать отображение ошибок.
Обычно вместо HTML-валидации используется самописный валидатор и компонент для отображения ошибок, а текстовое поле предоставляется UI-китом. Кстати, поле с типом URL я ни разу не встречал.
Использовать библиотеку для валидации. Способ кажется надежным, но добавление в проект новой зависимости — не самый лучший вариант. Особенно если задача не такая уж сложная. К тому же на поиск хорошей поддерживаемой библиотеки уйдет время, которого нет. Решение должно быть максимально простым и быстрым.
Сделать запрос по нашему URL с использованием fetch
или axios
. Такой способ неудобен в браузере. Ссылка может быть невалидной из-за того, что ее еще не ввели до конца, но при этом будет попытка сделать запрос. Если в проекте используется система логирования ошибок наподобие Sentry, в ней будет куча однотипных ошибок. Придется что-то придумывать, чтобы логи были в чистоте и порядке.
Даже валидная ссылка не гарантирует нам доступность ресурса. Могут быть проблемы с CORS-политиками, владелец может вовремя не оплатить сервер или домен, или просто получим, к примеру, 502 Bad Gateway. Если что-нибудь может пойти не так, оно обязательно пойдет.
Проверка будет асинхронной, что может привести к большим задержкам при валидации. При этом нет гарантии, что результат будет получен. Для браузерной проверки этот способ явно не подходит.
Написать регулярное выражение. Регулярные выражения — довольно мощный инструмент, особенно в умелых руках и с исчерпывающим набором тестов. Но если мы захотим провалидировать все возможные варианты ссылки, потребуется написать большую и сложную для понимания и поддержки регулярку. И с вероятностью 99,9% эта регулярка пропустит что-нибудь нехорошее.
Если же возьмем готовую регулярку, добавить в нее какое-то правило будет не тривиальной задачей. Есть два типа людей: те, кто понимает регулярные выражения, и те, кто заботится о ментальном здоровье коллег. Шутка, но с долей правды.
Использовать встроенный в JavaScript инструмент new URL()
. Инструмент своим названием намекает, что должен распознать нашу ссылку, а если это не ссылка, получим ошибку. Звучит как серебряная пуля. Я решил остановиться на этом варианте, потому что надо делать просто, а сложно получится как-нибудь само.
Проблема простого валидатора
Я написал функцию валидации, получилось чуть больше чем пара строк. Никакой головной боли, простой и понятный код, даже на ревью не будет вопросов.
function validateUrl(url) {
try {
new URL(url);
return 'ok';
}
catch {
return 'its not good';
}
}
Код написан, работает, ревью пройдено... profit. А потом тестировщик принес ошибку при интеграционном тестировании. На форме ввели ссылку, функция validateUrl говорит, что ссылка валидная, но бэкенд отказывается ее принимать по неизвестной причине. Хотелось бы поверить, что, как обычно, виноват бэкенд. Так как он написан на другом языке, то и инструмент для валидации ссылки будет другой.
Казалось бы, ссылки должны проверяться одинаково, но по какой-то причине механизмы работают по-разному. Сначала найдем виновного, а потом поймем, что делать.

Яблоком раздора стала ссылка http://my-site.com!
. С виду обычная, но в конце стоит восклицательный знак — обычные приколы тестировщиков. «Неужели это законно? Незаконно!» К сожалению, это не валидное имя хоста. Если мы попытаемся сделать реальный запрос, получим ошибку. Получается, что проблема на стороне браузера, бэкендеры могут выдохнуть спокойно. А мы продолжаем разбираться с тем, как все работает и почему.
Алгоритмы работы URL API
Я изучил, как работает URL API, и делюсь упрощенным алгоритмом.
Сначала определяем схему или протокол — это все, что стоит до двоеточия, в нашем случае «http».
После парсер проверяет, хост — это IPv4, IPv6 или обычная строка. Первые два явно не наш случай, мы имеем дело со строкой. В строке идет поиск первого вхождения запрещенного символа, например /?#
, или управляющего символа, например \n
или \t
. Полный список символов можно найти в спецификации.
Символ !
не запрещен, поэтому парсер включает его в состав хоста. Это приводит к тому, что имя хоста получается my-site.com!
. Но если посмотреть правила формирования доменных имен, можно увидеть, что доменные имена состоят из символов a—z
,0—9
и дефиса, который не может стоять в начале и в конце. Получается, что new URL()
может выдать некорректную ссылку за валидную? Понятно, что ничего не понятно.
Если есть спецификации и RFC для правильной обработки ссылок, почему же кто-то ей следует, а new URL()
— нет? Кто вообще занимается разработкой инструментов и описанием стандартов? И почему они сделали такое поведение?
Придется искать ответы в истории веба. В мире веб-технологий существует две ключевые организации, занимающиеся разработкой стандартов: W3C и WHATWG.
W3C, World Wide Web Consortium, — некоммерческая организация, которая с 1994 года занимается разработкой стандартов. Среди ее достижений — спецификации HTML 4, CSS, XML и другие. После HTML4 W3C сфокусировалась на разработке XHTML — более строгой и формальной версии языка. XHTML вызвал разногласия, в том числе у разработчиков браузеров.
В 2004 году появилась WHATWG, Web Hypertext Application Technology Working Group, — независимая рабочая группа, в которую вошли представители Apple, Mozilla Foundation и Opera Software. Целью WHATWG стало развитие HTML в сторону удобства и практичности для реальных веб-разработчиков.
Последняя номерная спецификация HTML5 была разработана именно WHATWG. Она включала в себя множество полезных возможностей, таких как семантические теги, поддержка мультимедиа и API для веб-приложений. Позже W3C взял эту спецификацию и официально утвердил ее как рекомендацию в 2014 году.
Сейчас разработкой стандартов занимается WHATWG в формате «живых стандартов» (living standard). Это означает, что стандарт постоянно обновляется, дополняется и отражает текущее состояние технологий с попыткой заглянуть в будущее. При таком подходе технологии развиваются быстрее, чем при более формальном подходе. W3C придерживаются формального подхода, когда стандарты проходят этапы разработки, утверждения и становятся официальными рекомендациями, после чего уже запускается разработка инструментария.
Получается, что есть две организации, которые создают стандарты для веба. Разве мы не получим два разных стандарта? К этому все шло. Но стандарты веба разрабатывались по большей части для браузера, а браузеры больше склонны следовать за WHATWG, по сути, разрабатывая стандарты для самих себя.
В 2019 году WHATWG и W3C подписали меморандум, согласно которому WHATWG получила ведущую роль в разработке стандартов HTML и DOM, а W3C стал утверждать эти стандарты как свои официальные рекомендации. Так организации сотрудничают друг с другом и стараются идти в одном направлении.
Теперь мы знаем, что WHATWG ответственен за разработку WHATWG URL Standard, который и описывает поведение URL API. Цели стандарта:
1. Привести RFC 3986 (URI) и RFC 3987 (IRI) в соответствие с современными реализациями и одновременно признать RFC устаревшими. Анализ URL-адресов должен стать таким же надежным, как и анализ HTML.
2. Стандартизировать термин URL. URI и IRI просто сбивают с толку. На практике для обоих используется один алгоритм.
3. Полностью определить существующий JavaScript API для URL и добавить улучшения для упрощения работы с ним.
Что такое URI, IRI, URN
URI, Uniform Resource Identifier, — строка, которая однозначно идентифицирует ресурс. Это общий термин, охватывающий как адреса, так и имена ресурсов.
IRI, Internationalized Resource Identifier, — расширение URI, которое позволяет использовать символы из любых языков, включая кириллицу, иероглифы и другие. То есть это URI, поддерживающий юникод.
URN, Uniform Resource Name, — тип URI, который указывает имя ресурса, но не его местоположение. Он используется для уникального и постоянного обозначения ресурса, даже если он больше недоступен по какому-либо адресу.
URL, Uniform Resource Locator, — тип URI, который указывает, где находится ресурс и как его получить (например, по протоколу HTTP или FTP).
По сути, URI — это общий способ ссылаться на ресурсы, независимо от того, по адресу это или по имени. URL говорит нам, где и как получить ресурс, а URN — как он называется.
URI, URL и IRI мало чем различаются и выглядят примерно так: <здесь могла быть ваша реклама ссылка>. А вот URN выглядит немного иначе, например так: urn:isbn:0451450523
.
Как устроен URL
Мы разобрались с тем, кто отвечает за URL API. Давайте выясним, что такое URL вообще. Если скажем, что это ссылка в браузере, будем правы, но отчасти. Ссылка является URL, но не каждый URL является ссылкой.
URL — единообразный указатель местонахождения ресурса, адрес ресурса в интернете. Например, всем известная ссылка https://www.google.com.
А что еще может быть URL? Вспомним наш первый hello world, написанный в index.html, который выглядел как-то так:
<!DOCTYPE html>
<html>
<head>
<title>Hello, World!</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
Смахнув слезу ностальгии, откроем его в браузере и получим в адресной строке file:///user/index.html
. А будет ли это URL? Как ни странно, да, это будет валидный URL.
Обратимся к RFC1738, чтобы узнать, как формируется URL. RFC1738 описывает структуру URL так:
<scheme>:<scheme-specific-part>
Если полистать RFC дальше, можно увидеть список возможных scheme: ftp, http, gopher, mailto, news, nntp, telnet, wais, file, prospero. В списке нет как минимум «нового» https. Некоторые из них практически не используются, например gopher. Скорее всего, половину схем вы видите впервые, так же как я. За 30 лет RFC изрядно устарел, отметим этот факт.
В наших примерах обе части представляют собой две непустые ASCII-строки, разделенные двоеточием. Конечно, там есть свои ограничения по длине, допустимые символы и так далее. Но получается, что примеры можно считать валидными с точки зрения высокоуровнего URL. Взглянем на примеры еще раз, чтобы закрепить.
Пример |
scheme |
scheme-specific-part |
http |
||
file:///home/user/documents/report.pdf |
file |
///home/user/documents/report.pdf |
Если мы знаем, какой у нас scheme, в нашем случае http, то почему URL API не может провалидировать нам scheme-specific-part? Сделать это возможно, но за последнее время произошло много изменений, появились кириллические доменные имена, раз в несколько лет создаются новые схемы.
Если под каждое нововведение добавлять валидацию, можно сломать что-нибудь как в браузерах, так и на бэкенде, если он написан на JS. А еще не все символы до сих пор имеют четкое описание, и если вдруг они найдут свое место в этом мире, то и валидатор под них придется переписывать.
Версии стандарта живые и постоянно развиваются, а для удобства разработки сложная валидация отдана разработчикам. Реализация scheme-specific-part в виде ASCII-строки позволяет иметь широкую обратную и прямую совместимость в множестве браузеров. К сожалению, это порождает нашу проблему с восклицательным знаком. Но, как мы знаем, «с большой силой приходит большая ответственность», поэтому простим коллег из WATHWG.
Одно из ограничений для разработки — то, что URL API старается сделать гораздо больше работы, чем просто проверить строки на соответствие URL. Сюда входит конвертация символов из UTF-16 в UTF-8. Например "привет мир" станет %D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82+%D0%BC%D0%B8%D1%80
.
Если же у нас доменное имя написано кириллицей, нужно конвертировать его в Punycode
, и URL API справляется с этим за нас. А если брать что-то из относительно нового, то буквально пару лет назад мы получили возможность открывать приложения из браузера, и сейчас это считается нормой. Например, для установки расширения для VS Code будет примерно такой URL vscode:extension/<extension-name>
, и с точки зрения URL API он тоже будет валидным. Новые функции при обработке URL появляются довольно часто, и, если WHATWG будет придерживаться классического режима утверждения стандартов, различные нововведения мы будем видеть крайне редко.
Теперь вернемся к варианту с текстовым полем с типом URL, который должен проверить ссылки с помощью встроенной HTML-валидации. Валидация внутри поля с типом URL соответствует тому же механизму, что и в new URL()
. Хотя мы можем добавить к этому полю дополнительный атрибут pattern
, в котором с помощью регулярного выражения можно провалидировать наше поле так, как нам надо. И если открыть документацию, как раз там можно увидеть интересный момент.
EN:
Warning: HTML form validation is not a substitute for scripts that ensure that the entered data is in the proper format.
RU:
Валидация форм в HTML не может заменить сценарии, которые обеспечивают правильный формат вводимых данных.
Мы знаем, что можем получить валидный по структуре URL, но проверять его валидность для специфичного типа надо уже самим. Дополнительно я захотел узнать, как валидируют ссылки другие разработчики. Для этого я пошел в очень известный поисковик и к паре GPT с вопросом „How to validate http link in js?“ Самым популярным оказался вариант с использованием new URL()
, в некоторых случаях предлагалось добавить проверку протокола на HTTP и HTTPS. Но мы-то теперь знаем, что так можно пропустить кучу невалидных значений. На втором месте были монструозного вида регулярные выражения. А на третьем — использование библиотек для валидации.
Какого варианта стоит придерживаться, каждый решает сам, предварительно взвесив все аргументы за и против. Я пошел гибридным путем: проверил валидность ссылки с помощью new URL()
, а после добавил проверки отдельных его частей с помощью регулярных выражений. Количество проверок в своем коде я регулирую требованиями к уровню качества данных. Если нужно проверить известный и зафиксированный формат ссылки, например профиль в github, могу провалидировать его более строгим регулярным выражением без проверки через URL API.
Выводы
Мы выяснили, что WHATWG URL Standard описывает URL API для JavaScript в формате живого стандарта. URL API позволяет нам провести парсинг URL, но при этом упрощая валидацию, чтобы иметь максимальную совместимость — как обратную, так и прямую. Более детальные проверки возлагаются на плечи самих разработчиков.
Мне кажется, стоит периодически уделять внимание изучению инструмента, так как его поведение может неприятно удивить в самый неподходящий момент. В прошлой статье — что я понял, когда написал много тестов, — я вскользь зацепил тему того, что даже 100%-е тестовое покрытие не гарантия того, что твои тесты проверяют хоть что-нибудь.
И если что-нибудь может пойти не так, оно пойдет.
А на десерт... Угадай, какие из этих URL будут обработаны с ошибкой?
new URL("file:///home/user/documents/report.pdf");
new URL("data:text/plain;charset=utf-8,Hello%20World");
new URL("blob:https://example.com/1234-5678-90ab-cdef");
new URL("text:я валидный url");
new URL("ftp://user:pass@ftp.example.net:21/path/to/file.txt");
new URL("mailto:support@example.com?subject=Question");
new URL("tel:+700000000000000000000");
new URL("myapp://open/settings?tab=general");
new URL("http://192.168.1.1:8080/path?query=value");
new URL("http://[2001:db8::1]:3000/path?query=value");
new URL("javascript:alert('I am valid URL, lol!')");
new URL("from-T-Bank:to-habr-with-love");
David_Osipov
Ну ёмаё, пойду тогда дописывать доп. проверки в своём пете. А вам спасибо!
Goodzonchik Автор
И вам спасибо! Рад, что эти знания пригодятся кому-то еще!