API Testing in the Repo with .http Files: Variables, Environments and Request Chaining in Visual Studio, Rider and VS Code
.http files live in the repo, but the same extension has three dialects: Visual Studio's {{login.response.body.$.accessToken}}, $dotenv and $shared go out unresolved in the JetBrains HTTP Client (ijhttp 2026.2), and 401s are not failures unless you write a test. Two files for Bookstore, http-client.env.json and secret providers, VS Code settings, a JUnit report with client.test, the http-client.private.env.json .gitignore misses, and a token caught by gitleaks.
Version note: The code in this post was built and run with .NET 10; the .http files were run with the JetBrains HTTP Client CLI 2026.2 (ijhttp, Docker image), and the secret scan was done with gitleaks 8.30. The behaviour of Visual Studio and the VS Code REST Client is taken from the official Microsoft and extension documentation; I did not run these two editors for this post.
Guess First
There is an .http file written in Visual Studio that works fine in Visual Studio. The first request is the login, the second uses the token like this:
GET {{HostAddress}}/api/auth/me
Authorization: Bearer {{login.response.body.$.accessToken}}
The Rider user on the team opens the same file and runs it. What happens: does Rider stop with an error, warn before sending, or send the request and get a status code? If the same file runs in CI with JetBrains' ijhttp CLI, does the build go red or green? The answer is the least-known side of .http files: the same extension, three different dialects.
What Is an .http File, and Why Does It Live in the Repo?
An .http file is a plain text file where you write HTTP requests almost exactly as they look on the wire: request line, headers, a blank line, the body. Requests are separated by ###, and lines starting with # or // are comments. The format became popular through the REST Client extension for VS Code, JetBrains IDEs have shipped their own HTTP Client with the same extension for years, and Visual Studio supports it with its own editor since 17.8 (the prerequisite in Microsoft's documentation). That is why the ASP.NET Core Web API template puts a <Project>.http file next to the project.
The "lives in the repo" column of the first post's matrix fits this tool best. The file sits next to the API project, the pull request that changes an endpoint changes it too, and the reviewer sees both together. There is a price: a file works in one IDE, and which IDE depends on the syntax you wrote it in. In this post I wrote two files for Bookstore.Api: src/Bookstore.Api/Bookstore.Api.http, next to the API project in the syntax shared by Visual Studio and VS Code, and tests/http/bookstore-flow.http, written in the JetBrains dialect so it can run in CI.
Syntax: Requests, Variables and Dynamic Values
The shared scenario for Visual Studio and the VS Code REST Client:
# Bookstore.Api: login -> books -> cart -> order -> read the order
# Visual Studio and VS Code (REST Client) syntax. Environment: http-client.env.json.
# The password comes from .env (BOOKSTORE_PASSWORD=...), which is not committed; see .env.example.
# @name login
POST {{HostAddress}}/api/auth/login
Content-Type: application/json
{
"email": "{{Email}}",
"password": "{{$dotenv BOOKSTORE_PASSWORD}}"
}
###
# @name books
GET {{HostAddress}}/api/books?page=1&search=Hobbit
Accept: application/json
###
# @name cart
POST {{HostAddress}}/api/carts
Authorization: Bearer {{login.response.body.$.accessToken}}
###
PUT {{HostAddress}}/api/carts/{{cart.response.body.$.id}}/items/{{books.response.body.$.items[0].isbn}}
Authorization: Bearer {{login.response.body.$.accessToken}}
Content-Type: application/json
{
"quantity": 2
}
###
# @name order
POST {{HostAddress}}/api/orders
Authorization: Bearer {{login.response.body.$.accessToken}}
Content-Type: application/json
X-Request-Id: {{$guid}}
{
"cartId": "{{cart.response.body.$.id}}",
"customerName": "Ada Lovelace",
"customerEmail": "{{Email}}"
}
###
GET {{order.response.headers.Location}}
Authorization: Bearer {{login.response.body.$.accessToken}}
###
The pieces: {{HostAddress}} and {{Email}} are variables that come from the environment. They could also be defined inside the file with a line like @HostAddress = http://localhost:5080; in Visual Studio a definition in the file overrides the environment's, so I do not put values that change per environment into the file. {{$guid}} generates a new GUID for every request. The other dynamic values in Microsoft's documentation are {{$randomInt min max}}, {{$datetime iso8601}}, {{$localDatetime rfc1123}} and {{$timestamp}}; dates also accept an offset such as -1 d. {{$processEnv BOOKSTORE_PASSWORD}} reads an operating system environment variable, and {{$dotenv BOOKSTORE_PASSWORD}} reads a value from a .env file. Visual Studio looks for .env in the project folder, REST Client in the folder of the .http file; since I put the file in the API project's folder, it is the same place for both.
Request Chaining: # @name and JSONPath
When you put # @name login above a request, later requests reach its response with {{login.response.body.$.accessToken}}. The syntax is {{name.(response|request).(body|headers).(*|JSONPath|XPath|header-name)}}: JSONPath (RFC 9535) for the body, the header's name for headers. The file above uses all three kinds: the token from the body ($.accessToken), the book's ISBN from the first element of an array ($.items[0].isbn), and the order's address from a header ({{order.response.headers.Location}}).
Two details, both in Microsoft's documentation. First, to reach a named request's response you must have run that request by hand first; the editor does not run the chain from the start for you. If you click cart without running login, an unresolved expression stays in the header. Second, if a request has been run more than once, the latest response is used. That means all you need to do to refresh the token every 15 minutes is click login once more.
Environments: http-client.env.json, $shared and Files That Are Not Committed
Visual Studio looks for the first http-client.env.json starting from the .http file's folder and moving up through the parent folders; you pick the environment from the selector at the top right of the editor. For Bookstore:
{
"$shared": {
"Email": "ada@example.com"
},
"dev": {
"HostAddress": "http://localhost:5080"
},
"staff": {
"HostAddress": "http://localhost:5080",
"Email": "grace@example.com"
}
}
$shared gives every environment a default; the staff environment overrides Email and runs the same flow as Grace. This file is committed and has no secrets in it. Personal values go into an http-client.env.json.user next to it; if the same variable exists in both files for the same environment, the one in the .user file wins.
Visual Studio has one more step: secret providers. In the environment file you give a provider instead of a variable's value. AspnetUserSecrets reads the value from the project's user secrets, AzureKeyVault from a Key Vault (you need to be signed into Visual Studio with an account that can access that vault), and Encrypted from a value encrypted with Windows DPAPI:
{
"dev": {
"Password": {
"provider": "AspnetUserSecrets",
"secretName": "Auth:DemoPassword"
}
}
}
For a secret the team has to share, Key Vault fits best; user secrets and DPAPI stay tied to a machine and a person. In Bookstore the demo password is for local development, so I kept it simple and used .env. Only an empty .env.example goes into the repo.
The VS Code REST Client does not read http-client.env.json; the extension's README defines environments in VS Code settings under rest-client.environmentVariables, and $shared exists there too. Since Bookstore's .gitignore leaves .vscode/settings.json committable, I put the environments there:
{
"rest-client.environmentVariables": {
"$shared": {
"Email": "ada@example.com"
},
"dev": {
"HostAddress": "http://localhost:5080"
},
"staff": {
"HostAddress": "http://localhost:5080",
"Email": "grace@example.com"
}
}
}
In REST Client you send a request with Ctrl+Alt+R and switch environments with Ctrl+Alt+E. In Visual Studio you click the Send Request link above the request and reach the environment selector with F6. Visual Studio's Endpoints Explorer window (View menu, Other Windows) lists the API's endpoints; right-click an endpoint and choose Generate Request, and a skeleton of that request is added to the .http file named after the project. It is the fastest way to start from an empty file.
The Dialect Difference: The Same File in the JetBrains HTTP Client
For the answer to the guessing question I ran the file above, unchanged, with JetBrains' CLI ijhttp. ijhttp is the command-line version of the HTTP Client engine inside Rider and IntelliJ; it comes as a Docker image and runs without installing Java. -D points localhost inside the container to the host machine; -L VERBOSE prints the headers and bodies sent:
$ docker run --rm -v "$PWD:/workdir" jetbrains/intellij-http-client -D -L VERBOSE --no-progress \
-e dev -v http-client.env.json Bookstore.Api.http \
| grep -E '^Request|^Authorization|^X-Request-Id|^HTTP/1.1|"password"|completed|^RUN'
Request 'login' POST http://localhost:5080/api/auth/login
"password": "{{$dotenv BOOKSTORE_PASSWORD}}"
HTTP/1.1 401 Unauthorized
Request 'books' GET http://localhost:5080/api/books?page=1&search=Hobbit
HTTP/1.1 200 OK
Request 'cart' POST http://localhost:5080/api/carts
Authorization: Bearer {{login.response.body.$.accessToken}}
HTTP/1.1 401 Unauthorized
Request '#4' PUT http://localhost:5080/api/carts/{{cart.response.body.$.id}}/items/{{books.response.body.$.items[0].isbn}}
Request 'order' POST http://localhost:5080/api/orders
Authorization: Bearer {{login.response.body.$.accessToken}}
X-Request-Id: {{$guid}}
HTTP/1.1 401 Unauthorized
Request '#6' GET http://{{order.response.headers.Location}}
6 requests completed, 2 have failed tests
RUN FAILED
So Rider does not stop with an error, it sends the requests. The $dotenv, $guid and request variable expressions go out as they are; the password is the text {{$dotenv BOOKSTORE_PASSWORD}}, so login returns 401, and since a literal expression goes out instead of the token, cart and order get 401 too. The run is red, but not because of those 401s: only the two requests whose URLs stayed invalid counted as "failed". In the JetBrains world a 401 is not a failure unless you write a client.test. And before I passed -v http-client.env.json, the CLI did not start at all, saying "Environment files are not set while environment name passed"; inside Docker you have to pass the environment file explicitly.
I also saw that the same tool does not know $shared: when I moved Email into $shared, login sent an unresolved variable instead of the e-mail address and the test failed. I collected what I observed and what the documentation says into one table:
| Feature | Visual Studio | VS Code REST Client | JetBrains (Rider, ijhttp 2026.2) |
|---|---|---|---|
###, @name = value, {{name}} | Yes | Yes | Yes |
{{login.response.body.$.x}} | Yes | Yes | No, the expression went out as is |
| GUID and date | $guid, $datetime | $guid, $datetime | $uuid, $isoTimestamp |
| Environment variable and .env | $processEnv, $dotenv | $processEnv, $dotenv | {{$env.NAME}}, no .env |
| Environment file | http-client.env.json + $shared | VS Code settings + $shared | http-client.env.json, no $shared |
| Personal/secret file | http-client.env.json.user, secret providers | Settings or .env | http-client.private.env.json |
| Scripts and assertions | No | No | < {% %}, > {% %}, client.test |
| CLI | No | No | ijhttp |
The practical decision: if the team uses mixed IDEs and the files are only for manual checks, stay in the common subset: ###, file variables and HostAddress from the environment. And ask everyone to put the token into their own personal environment file by hand. If you need request chaining and CI, pick one dialect as a team and write the file for it. In Bookstore I separated the two: the file next to the API project is for Visual Studio and VS Code users, the file under tests/http/ is in the JetBrains dialect and for CI.
The JetBrains Dialect: Response Handlers, client.test and ijhttp
In the JetBrains HTTP Client, chaining is done with a JavaScript response handler written below the request: the > {% ... %} block sees the response as the response object, leaves values for later requests with client.global.set, and writes assertions with client.test and client.assert. The beginning and end of tests/http/bookstore-flow.http:
# Bookstore flow for the JetBrains HTTP Client (Rider, IntelliJ, ijhttp CLI).
# Password: http-client.private.env.json (not committed) or ijhttp -P Password=...
### Login
POST {{HostAddress}}/api/auth/login
Content-Type: application/json
{
"email": "{{Email}}",
"password": "{{Password}}"
}
> {%
client.test("login returns a bearer token", () => {
client.assert(response.status === 200, "status " + response.status);
client.assert(response.body.tokenType === "Bearer", "tokenType " + response.body.tokenType);
});
client.global.set("token", response.body.accessToken);
%}
### Books
GET {{HostAddress}}/api/books?page=1&search=Hobbit
Accept: application/json
> {%
client.test("The Hobbit is in the catalog", () => {
client.assert(response.body.items.length === 1, "items " + response.body.items.length);
});
client.global.set("isbn", response.body.items[0].isbn);
%}
### Place order
POST {{HostAddress}}/api/orders
Authorization: Bearer {{token}}
Content-Type: application/json
X-Request-Id: {{$uuid}}
{
"cartId": "{{cartId}}",
"customerName": "Ada Lovelace",
"customerEmail": "{{Email}}"
}
> {%
client.test("order is created with a Location", () => {
client.assert(response.status === 201, "status " + response.status);
client.assert(response.headers.valueOf("Location") !== null, "no Location header");
});
client.global.set("orderUrl", response.headers.valueOf("Location"));
%}
The cart requests in between follow the same pattern; the whole file is in the repo. The password sits in http-client.private.env.json. JetBrains' documentation says this file's values override the public file's, but elsewhere on the same page it says the opposite. I tried it: with a wrong password in the public file and the right one in the private file, the private one won in ijhttp 2026.2 and the flow passed. The run and the JUnit report:
$ docker run --rm -v "$PWD:/workdir" jetbrains/intellij-http-client -D --no-progress \
-e dev -v http-client.env.json -p http-client.private.env.json bookstore-flow.http --report
Request 'Login' POST http://localhost:5080/api/auth/login
Request 'Books' GET http://localhost:5080/api/books?page=1&search=Hobbit
Request 'Create cart' POST http://localhost:5080/api/carts
Request 'Add two copies' PUT http://localhost:5080/api/carts/01a1d955-33a7-7440-8fd7-55be56d8931c/items/9780261102217
Request 'Place order' POST http://localhost:5080/api/orders
Request 'Read order' GET http://localhost:5080/api/orders/5
6 requests completed, 0 have failed tests
RUN SUCCESSFUL
$ head -6 reports/report.xml
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="18" name="IntelliJ IDEA HTTP Client">
<testsuite tests="18" name="bookstore-flow" failures="0" errors="0" skip="0">
<testcase classname="http.Login" name="Response" time="0.656" />
<testcase classname="http.Login" name="login returns a bearer token" time="0.006" />
<testcase classname="http.Login" name="Response Handler" time="1.282" />
--report has to come after the file name; written before it, since it is an option that can take a value, it treats the file name as the report folder and stops with "Missing required parameter: 'files'". When I ran it with a wrong password (-P Password=wrong), the exit code was 1 and the failure chain stayed readable:
Request 'Login' POST http://localhost:5080/api/auth/login
Failed bookstore-flow.http#0 login returns a bearer token
AssertionError: status 401
this.assert (at js-graalvm-response-handler.js:45)
:=> (at /workdir/bookstore-flow.http:15)
Request 'Books' GET http://localhost:5080/api/books?page=1&search=Hobbit
Request 'Create cart' POST http://localhost:5080/api/carts
Failed bookstore-flow.http#2 cart is created
...
6 requests completed, 5 have failed tests
RUN FAILED
Once the token was empty in the middle of the chain, the URLs of the following requests kept unresolved variables like {{cartId}}, and the CLI printed a long Java stack trace for each. That is no problem for the report, but know that you have to look for the first "Failed" line in the logs. Passing the password with -P in CI puts it on the command line and in the process list; creating http-client.private.env.json from the pipeline's secret is cleaner.
.gitignore: Which Files Are Protected by Default?
Bookstore's .gitignore comes from the dotnet new gitignore template. I asked git check-ignore about the three secret files:
$ git check-ignore -v src/Bookstore.Api/.env src/Bookstore.Api/http-client.env.json.user tests/http/http-client.private.env.json
.gitignore:7:.env src/Bookstore.Api/.env
.gitignore:12:*.user src/Bookstore.Api/http-client.env.json.user
The first two are protected: the current version of the template includes .env and *.user. The third is not in the list, so http-client.private.env.json can be committed. JetBrains' documentation also says Rider does not track this file in its own Git integration but does not add it to .gitignore; someone who runs git add . from a terminal commits it. I added these lines to Bookstore:
# HTTP client secrets and reports (Rider / ijhttp)
http-client.private.env.json
tests/http/reports/
Microsoft's documentation also warns that .env files might not be ignored by default. A .gitignore from an older template may not have the .env line; check your own repo with the same command.
In Production
Do not put production into the environment list of an .http file. The selector is one click away; it is easy to send a PUT request that runs with Grace's permissions in the staff environment to production by mistake. If you need staging, give it a loud name (staging-readonly) and use it with a separate file that has only GET requests.
If you run ijhttp in CI, wait for the API to come up. ijhttp has no retry of its own; if the API is not listening yet, the first request fails with a connection error. The curl --retry-connrefused line from the second post works as a wait step; in the fifth post we will set up the same arrangement in GitHub Actions for Bruno. The Docker image's latest tag changes with every JetBrains release; pin a version in CI so the dialect's behaviour does not change quietly one morning.
Response handlers run JavaScript. Running someone else's .http file in Rider means running their script on your machine. Review .http changes in pull requests from outside your team like code.
Common Pitfalls
Writing the token into the file. A line like @token = eyJ... looks like the shortest way. To check, I committed such a file into a repo and scanned it with gitleaks:
$ gitleaks git --no-banner --redact -v --no-color .
Finding: REDACTED
Secret: REDACTED
RuleID: jwt
Entropy: 5.576108
File: quick.http
Line: 2
Commit: 1f9636f8fc96981864ead5f7416dda53d5d3c294
Author: demo
Email: demo@example.com
Date: 2026-11-14T09:10:44Z
Fingerprint: 1f9636f8fc96981864ead5f7416dda53d5d3c294:quick.http:jwt:2
12:13PM INF 1 commits scanned.
12:13PM INF scanned ~447 bytes (447 bytes) in 94.6ms
12:13PM WRN leaks found: 1
Even if the token has expired, the information inside it can be read; run a secret scanner in a pre-commit hook or in CI, and always get the token from the environment or the login request.
Forgetting the @HostAddress line in the file. In Visual Studio a variable defined in the file overrides the environment file; you think you picked staging in the selector while the requests still go to the address in the file.
Clicking a request in the middle without running the chain from the start. In Visual Studio and REST Client a named request's response only exists if that request has been run; do not mistake a 401 caused by an old token for an API bug.
Mixing dialects in one file. Adding a > {% %} block to a Visual Studio file or using $dotenv in a JetBrains file leaves the file half-working for both sides; as we saw in the run above, this usually shows up not as an error but as a silent 401.
A Small Experiment
★☆☆ In Visual Studio, open Endpoints Explorer in the Bookstore.Api project, choose Generate Request for GET /api/books/{isbn} and edit the generated request to work with {{HostAddress}}. If you use Rider, write the same request by hand and run it in the staff environment.
★★☆ Add a request to bookstore-flow.http that uploads a cover as Grace. In the JetBrains HTTP Client you can read a multipart body from a file with < ./the-hobbit.png. In the response handler, test that the status is 200 and that contentType is image/png, then write a second test that confirms Ada's token gets 403.
★★★ Your team uses mixed IDEs. Remove request chaining from Bookstore.Api.http, write it with only the common subset (file variables, environment, a token entered by hand), and run the same file with ijhttp to confirm every request resolves. Write two sentences on what you lost and whether that is acceptable for the team.
.http files live in the repo, but they depend on a dialect and are hard to share with someone outside the team. In the next post we look at the most common answer to that problem: collections, environments, variable scopes, scripts, mock servers and the CLI in Postman.