File uploads
After this page you can send a file to a mutation, send several files in one call, set where a file is accepted and how large the files may be, and read a report of a call that sent a file.
Send a file
Pass an UploadFile as the value of an
argument whose type is an upload scalar, such as Upload. The client sends the
call as a multipart request, by the
GraphQL multipart request specification
version 2.1.0. No extra package is needed.
from pytest_graphql import UploadFile
avatar = UploadFile(b"\x89PNG...", filename="avatar.png", content_type="image/png")
receipt = gql.mutation("uploadAvatar", file=avatar)
assert receipt.filename == "avatar.png"
assert receipt.size == 7
assert receipt.content_type == "image/png"
content_type defaults to application/octet-stream. It must be printable
ASCII, because it is written into a header line of the request. A line break or
another control character raises ArgumentError.
Read a file from disk
UploadFile.from_path() reads the file once, when you call it, and keeps the
bytes in memory. The filename is the last part of the path only, so no local
directory reaches the server. The type is guessed from the filename, and you can
give it yourself.
import tempfile
from pathlib import Path
from pytest_graphql import UploadFile
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "fixtures" / "notes.txt"
path.parent.mkdir()
path.write_bytes(b"hello")
notes = UploadFile.from_path(path)
report = UploadFile.from_path(path, content_type="text/markdown")
assert notes.filename == "notes.txt"
assert notes.content_type == "text/plain"
assert report.content_type == "text/markdown"
assert gql.mutation("uploadAvatar", file=notes).size == 5
A file object is not accepted. The client may send a call again after a failed connection, and it must send the same bytes each time.
Send several files
A file can sit inside an input object and inside a list, at any depth. The same
UploadFile object at two places is sent once, and the server receives it at
both.
from pytest_graphql import UploadFile
cover = UploadFile(b"cover", filename="cover.txt")
page = UploadFile(b"page one", filename="page.txt")
receipts = gql.mutation(
"uploadAttachments", input={"note": "draft", "files": [cover, page, cover]}
)
assert [receipt.filename for receipt in receipts] == [
"cover.txt",
"page.txt",
"cover.txt",
]
Where a file is accepted
A file is accepted only where the type is an upload scalar. The setting
upload_scalars names those scalars, and its default is ("Upload",). If your
schema calls the scalar something else, such as File, set
upload_scalars=("File",) in ClientConfig, or the ini option
gql_upload_scalars. Anywhere else, the call raises ArgumentError before
anything is sent. The message names the place and the scalars that accept a
file.
from pytest_graphql import ArgumentError, UploadFile
try:
gql.mutation("updateUser", id="u1", name=UploadFile(b"x", filename="a.txt"))
except ArgumentError as error:
message = str(error)
assert "$name holds an UploadFile, but the position of type 'String'" in message
assert "A file is accepted only where the type is 'Upload'." in message
A schema that has none of the named scalars refuses every file, and the message
tells you to set upload_scalars. A ScalarSpec that you register for an
upload scalar is not called on an UploadFile.
The size limit
max_upload_bytes bounds the total size of the files of one call. The default
is 32 MiB. A file sent at several places counts once. A larger call raises
ArgumentError before anything is sent. The ini option is
gql_max_upload_bytes.
from pytest_graphql import ArgumentError, UploadFile, build_client
client = build_client(
url="http://localhost:8000/graphql",
transport=gql.transport,
schema=gql.schema,
max_upload_bytes=1024,
)
try:
client.mutation("uploadAvatar", file=UploadFile(b"x" * 2000, filename="big.bin"))
except ArgumentError as error:
assert "more than max_upload_bytes=1,024" in str(error)
A server that needs a CSRF header
The client adds no CSRF header. Some servers refuse a multipart request unless it
carries a header that a plain HTML form cannot send. Apollo Server is one
example. Add the header that your server names with with_headers().
from pytest_graphql import UploadFile
client = gql.with_headers({"Apollo-Require-Preflight": "true"})
assert client.mutation("uploadAvatar", file=UploadFile(b"x", filename="a")).size == 1
In a whole suite, put the header in the ini option gql_headers instead.
Generated files
For an upload scalar with no registered ScalarSpec, gql.fake makes a small
UploadFile: 16 to 64 printable ASCII bytes, a name such as fake-1234.txt, and
the type text/plain. The same seed gives the same file.
from pytest_graphql import UploadFile
payload = gql.fake.AttachmentInput()
first = payload["files"][0]
assert isinstance(first, UploadFile)
assert first.content_type == "text/plain"
assert first.filename.startswith("fake-")
assert gql.fake.AttachmentInput() == payload
Files in reports
A report never shows the content of a file. Everywhere a variable is shown, a file is shown as its name, its size and its type:
variables: {"file": "<upload \"avatar.png\", 7 bytes, image/png>"}
The filename goes through the same removal of known secrets as other text. The
curl command of a report sends the same parts as the client: operations with
null at each file, map, and one -F part per file that names the file by its
filename and gives its type:
curl -sS -X POST http://localhost:8000/graphql -F 'operations="{...}"' -F 'map="{\"0\": [\"variables.file\"]}"' -F '0=@"avatar.png";headers="Content-Type: image/png"'
curl reads that file from the directory where you run the command, so you
supply it. Each part is in double quotes inside the -F value, so a ; or a ,
in a filename or a type stays part of it.
The command shows a file only where the report shows it. A file in a variable
that you list in redact_variables gets no part, and its filename is a known
secret, so it is removed from the rest of the text too. The map and the file
parts together are cut to max_diagnostic_bytes, as each field of a report is.
A position past the cut keeps its marker in operations.
Custom transports
A custom transport receives each UploadFile at its place in
request.variables. The multipart encoding is done by the built-in transport,
also when it runs an app in process. A custom transport that cannot send files
raises its own error. See Extending.