JavaScript / jQuery para descargar el archivo a través de POST con datos JSON

250

Tengo una aplicación web de una sola página basada en jquery. Se comunica con un servicio web RESTful a través de llamadas AJAX.

Estoy tratando de lograr lo siguiente:

  1. Envíe una POST que contenga datos JSON a una URL REST.
  2. Si la solicitud especifica una respuesta JSON, se devuelve JSON.
  3. Si la solicitud especifica una respuesta PDF / XLS / etc., se devuelve un binario descargable.

Tengo 1 y 2 trabajando ahora, y la aplicación jquery del cliente muestra los datos devueltos en la página web creando elementos DOM basados ​​en los datos JSON. También tengo # 3 trabajando desde el punto de vista del servicio web, lo que significa que creará y devolverá un archivo binario si se le dan los parámetros JSON correctos. Pero no estoy seguro de la mejor manera de lidiar con el n. ° 3 en el código JavaScript del cliente.

¿Es posible recuperar un archivo descargable de una llamada ajax como esta? ¿Cómo consigo que el navegador descargue y guarde el archivo?

$.ajax({
    type: "POST",
    url: "/services/test",
    contentType: "application/json",
    data: JSON.stringify({category: 42, sort: 3, type: "pdf"}),
    dataType: "json",
    success: function(json, status){
        if (status != "success") {
            log("Error loading data");
            return;
        }
        log("Data loaded!");
    },
    error: function(result, status, err) {
        log("Error loading data");
        return;
    }
});

El servidor responde con los siguientes encabezados:

Content-Disposition:attachment; filename=export-1282022272283.pdf
Content-Length:5120
Content-Type:application/pdf
Server:Jetty(6.1.11)

Otra idea es generar el PDF y almacenarlo en el servidor y devolver JSON que incluye una URL al archivo. Luego, emita otra llamada en el controlador de éxito de ajax para hacer algo como lo siguiente:

success: function(json,status) {
    window.location.href = json.url;
}

Pero hacer eso significa que necesitaría hacer más de una llamada al servidor, y mi servidor necesitaría construir archivos descargables, almacenarlos en algún lugar y luego limpiar periódicamente esa área de almacenamiento.

Debe haber una manera más simple de lograr esto. Ideas?


EDITAR: después de revisar los documentos por $ .ajax, veo que el tipo de datos de respuesta solo puede ser uno xml, html, script, json, jsonp, text, por lo que supongo que no hay forma de descargar directamente un archivo usando una solicitud ajax, a menos que inserte el archivo binario al usar Esquema de URI de datos como se sugiere en la respuesta @VinayC (que no es algo que quiera hacer).

Entonces supongo que mis opciones son:

  1. No use ajax y, en su lugar, envíe una publicación de formulario e incruste mis datos JSON en los valores del formulario. Probablemente necesitaría meterse con iframes ocultos y demás.

  2. No use ajax y, en su lugar, convierta mis datos JSON en una cadena de consulta para crear una solicitud GET estándar y establecer window.location.href en esta URL. Es posible que necesite usar event.preventDefault () en mi controlador de clics para evitar que el navegador cambie de la URL de la aplicación.

  3. Use mi otra idea anterior, pero mejorada con sugerencias de la respuesta @naikus. Envíe la solicitud AJAX con algún parámetro que permita que el servicio web sepa que se está llamando a través de una llamada ajax. Si se llama al servicio web desde una llamada ajax, simplemente devuelva JSON con una URL al recurso generado. Si se llama directamente al recurso, devuelva el archivo binario real.

Cuanto más lo pienso, más me gusta la última opción. De esta forma puedo recuperar información sobre la solicitud (tiempo de generación, tamaño del archivo, mensajes de error, etc.) y puedo actuar sobre esa información antes de comenzar la descarga. La desventaja es la administración adicional de archivos en el servidor.

¿Alguna otra forma de lograr esto? ¿Algún pros / contras de estos métodos que debo tener en cuenta?

Tauren
fuente
66
este era "un poco" antes, entonces, ¿cómo es un duplicado
GiM
1
url = 'http://localhost/file.php?file='+ $('input').val(); window.open(url); La forma fácil de obtener los mismos resultados. Pon el encabezado en el archivo php. No es necesario enviar ningún ajax ni recibir solicitudes
abhishek bagul

Respuestas:

171

La solución de letronje solo funciona para páginas muy simples. document.body.innerHTML +=toma el texto HTML del cuerpo, agrega el iframe HTML y establece el innerHTML de la página en esa cadena. Esto eliminará cualquier enlace de evento que tenga su página, entre otras cosas. Crea un elemento y úsalo appendChilden su lugar.

$.post('/create_binary_file.php', postData, function(retData) {
  var iframe = document.createElement("iframe");
  iframe.setAttribute("src", retData.url);
  iframe.setAttribute("style", "display: none");
  document.body.appendChild(iframe);
}); 

O usando jQuery

$.post('/create_binary_file.php', postData, function(retData) {
  $("body").append("<iframe src='" + retData.url+ "' style='display: none;' ></iframe>");
}); 

Lo que esto realmente hace: realizar una publicación en /create_binary_file.php con los datos en la variable postData; si esa publicación se completa con éxito, agregue un nuevo iframe al cuerpo de la página. Se supone que la respuesta de /create_binary_file.php incluirá un valor 'url', que es la URL desde la que se puede descargar el archivo PDF / XLS / etc generado. Agregar un iframe a la página que hace referencia a esa URL dará como resultado que el navegador promocione al usuario a descargar el archivo, suponiendo que el servidor web tenga la configuración de tipo mime adecuada.

SamStephens
fuente
1
Estoy de acuerdo, esto es mejor que usar +=y actualizaré mi aplicación en consecuencia. ¡Gracias!
Tauren
3
Me gusta este concepto, sin embargo Chrome tiene este mensaje en la consola: Resource interpreted as Document but transferred with MIME type application/pdf. Entonces también me advierte que el archivo puede ser peligroso. Si visito el retData.url directamente, no hay advertencias ni problemas.
Michael Irey
Al mirar en Internet, parece que puede tener problemas con los archivos PDF en iFrames si su servidor no está configurando correctamente el Tipo de contenido y la Disposición de contenido. En realidad no he usado esta solución, solo he aclarado una solución anterior, así que no tengo ningún otro consejo, me temo.
SamStephens
1
@Tauren: si acepta que esta es una mejor respuesta, tenga en cuenta que puede cambiar la respuesta aceptada en cualquier momento, o incluso eliminar la marca de aceptación por completo. Simplemente marque la nueva respuesta y se transferirá la marca de aceptación. (Antigua pregunta, pero se mencionó recientemente en banderas).
BoltClock
55
¿Qué se supone que es retData.url?
Mark Thien
48

He estado jugando con otra opción que usa blobs. Me las arreglé para descargar documentos de texto y descargué archivos PDF (sin embargo, están dañados).

Usando la API blob podrás hacer lo siguiente:

$.post(/*...*/,function (result)
{
    var blob=new Blob([result]);
    var link=document.createElement('a');
    link.href=window.URL.createObjectURL(blob);
    link.download="myFileName.txt";
    link.click();

});

Esto es IE 10+, Chrome 8+, FF 4+. Ver https://developer.mozilla.org/en-US/docs/Web/API/URL.createObjectURL

Solo descargará el archivo en Chrome, Firefox y Opera. Esto utiliza un atributo de descarga en la etiqueta de anclaje para forzar al navegador a descargarlo.

JoshBerke
fuente
1
No hay soporte para el atributo de descarga en IE y Safari :( ( caniuse.com/#feat=download ). Puede abrir una nueva ventana y poner contenido allí, pero no está seguro acerca de un PDF o Excel ...
Marçal Juan
1
Debería liberar memoria del navegador después de llamar click:window.URL.revokeObjectURL(link.href);
Edward Brey
@JoshBerke Esto es viejo pero funciona de mi parte. ¿Cómo puedo hacer que abra mi directorio de inmediato para guardar en lugar de guardar en mi navegador? ¿También hay una manera de determinar el nombre del archivo entrante?
Jo Ko
2
Corromperá los archivos binarios, porque se convierten en cadenas usando codificación, y el resultado no está muy cerca del binario original ...
Gobol
2
Puede usar mimetypes para no corromper la información como se usa aquí con los archivos PDF: alexhadik.com/blog/2016/7/7/l8ztp8kr5lbctf5qns4l8t3646npqh
Mateo Tibaquira
18

Conozco este tipo de edad, pero creo que se me ocurrió una solución más elegante. Tuve exactamente el mismo problema. El problema que tenía con las soluciones sugeridas era que todas requerían que el archivo se guardara en el servidor, pero no quería guardar los archivos en el servidor, ya que presentaba otros problemas (seguridad: el archivo podía ser accedido por usuarios no autenticados, limpieza: cómo y cuándo se eliminan los archivos). Y como usted, mis datos eran objetos JSON complejos y anidados que serían difíciles de poner en un formulario.

Lo que hice fue crear dos funciones de servidor. El primero validó los datos. Si hubiera un error, sería devuelto. Si no fue un error, devolví todos los parámetros serializados / codificados como una cadena base64. Luego, en el cliente, tengo un formulario que solo tiene una entrada oculta y publicaciones en una segunda función del servidor. Establezco la entrada oculta en la cadena base64 y envío el formato. La segunda función del servidor decodifica / deserializa los parámetros y genera el archivo. El formulario podría enviarse a una nueva ventana o un iframe en la página y el archivo se abrirá.

Hay un poco más de trabajo involucrado, y tal vez un poco más de procesamiento, pero en general, me sentí mucho mejor con esta solución.

El código está en C # / MVC

    public JsonResult Validate(int reportId, string format, ReportParamModel[] parameters)
    {
        // TODO: do validation

        if (valid)
        {
            GenerateParams generateParams = new GenerateParams(reportId, format, parameters);

            string data = new EntityBase64Converter<GenerateParams>().ToBase64(generateParams);

            return Json(new { State = "Success", Data = data });
        }

        return Json(new { State = "Error", Data = "Error message" });
    }

    public ActionResult Generate(string data)
    {
        GenerateParams generateParams = new EntityBase64Converter<GenerateParams>().ToEntity(data);

        // TODO: Generate file

        return File(bytes, mimeType);
    }

en el cliente

    function generate(reportId, format, parameters)
    {
        var data = {
            reportId: reportId,
            format: format,
            params: params
        };

        $.ajax(
        {
            url: "/Validate",
            type: 'POST',
            data: JSON.stringify(data),
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            success: generateComplete
        });
    }

    function generateComplete(result)
    {
        if (result.State == "Success")
        {
            // this could/should already be set in the HTML
            formGenerate.action = "/Generate";
            formGenerate.target = iframeFile;

            hidData = result.Data;
            formGenerate.submit();
        }
        else
            // TODO: display error messages
    }
amersk
fuente
1
No he visto bien esta solución, pero vale la pena señalar que nada sobre la solución que usa create_binary_file.php requiere que los archivos se guarden en el disco. Sería completamente factible que create_binary_file.php genere archivos binarios en la memoria.
SamStephens
11

Hay una manera más simple, crear un formulario y publicarlo, esto corre el riesgo de restablecer la página si el tipo de mime de retorno es algo que un navegador abriría, pero para csv y tal es perfecto

El ejemplo requiere guión bajo y jquery

var postData = {
    filename:filename,
    filecontent:filecontent
};
var fakeFormHtmlFragment = "<form style='display: none;' method='POST' action='"+SAVEAS_PHP_MODE_URL+"'>";
_.each(postData, function(postValue, postKey){
    var escapedKey = postKey.replace("\\", "\\\\").replace("'", "\'");
    var escapedValue = postValue.replace("\\", "\\\\").replace("'", "\'");
    fakeFormHtmlFragment += "<input type='hidden' name='"+escapedKey+"' value='"+escapedValue+"'>";
});
fakeFormHtmlFragment += "</form>";
$fakeFormDom = $(fakeFormHtmlFragment);
$("body").append($fakeFormDom);
$fakeFormDom.submit();

Para cosas como html, texto y demás, asegúrese de que el tipo mime sea similar a application / octet-stream

código php

<?php
/**
 * get HTTP POST variable which is a string ?foo=bar
 * @param string $param
 * @param bool $required
 * @return string
 */
function getHTTPPostString ($param, $required = false) {
    if(!isset($_POST[$param])) {
        if($required) {
            echo "required POST param '$param' missing";
            exit 1;
        } else {
            return "";
        }
    }
    return trim($_POST[$param]);
}

$filename = getHTTPPostString("filename", true);
$filecontent = getHTTPPostString("filecontent", true);

header("Content-type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"$filename\"");
echo $filecontent;
aqm
fuente
8

En resumen, no hay una manera más simple. Debe realizar otra solicitud de servidor para mostrar el archivo PDF. Sin embargo, existen pocas alternativas, pero no son perfectas y no funcionarán en todos los navegadores:

  1. Mira el esquema de URI de datos . Si los datos binarios son pequeños, entonces quizás pueda usar javascript para abrir datos de paso de ventana en URI.
  2. La única solución de Windows / IE sería tener control .NET o FileSystemObject para guardar los datos en el sistema de archivos local y abrirlos desde allí.
VinayC
fuente
Gracias, no conocía el esquema de URI de datos. Parece que esa puede ser la única forma de hacerlo en una sola solicitud. Pero no es realmente la dirección que quiero ir.
Tauren
Debido a los requisitos del cliente, terminé resolviendo este problema usando los encabezados del servidor en la respuesta ( Content-Disposition: attachment;filename="file.txt"), pero realmente quiero probar este enfoque la próxima vez. Había usado URIdatos para miniaturas antes, pero nunca se me ocurrió usarlo para descargas; gracias por compartir esta pepita.
brichins
8

Ha pasado un tiempo desde que se hizo esta pregunta, pero tuve el mismo desafío y quiero compartir mi solución. Utiliza elementos de las otras respuestas, pero no pude encontrarlo en su totalidad. No utiliza un formulario o un iframe, pero sí requiere un par de solicitud post / get. En lugar de guardar el archivo entre las solicitudes, guarda los datos de la publicación. Parece ser a la vez simple y efectivo.

cliente

var apples = new Array(); 
// construct data - replace with your own
$.ajax({
   type: "POST",
   url: '/Home/Download',
   data: JSON.stringify(apples),
   contentType: "application/json",
   dataType: "text",

   success: function (data) {
      var url = '/Home/Download?id=' + data;
      window.location = url;
   });
});

servidor

[HttpPost]
// called first
public ActionResult Download(Apple[] apples)
{
   string json = new JavaScriptSerializer().Serialize(apples);
   string id = Guid.NewGuid().ToString();
   string path = Server.MapPath(string.Format("~/temp/{0}.json", id));
   System.IO.File.WriteAllText(path, json);

   return Content(id);
}

// called next
public ActionResult Download(string id)
{
   string path = Server.MapPath(string.Format("~/temp/{0}.json", id));
   string json = System.IO.File.ReadAllText(path);
   System.IO.File.Delete(path);
   Apple[] apples = new JavaScriptSerializer().Deserialize<Apple[]>(json);

   // work with apples to build your file in memory
   byte[] file = createPdf(apples); 

   Response.AddHeader("Content-Disposition", "attachment; filename=juicy.pdf");
   return File(file, "application/pdf");
}
Frank Rem
fuente
1
Creo que esta solución me gusta más. En mi caso, aunque estoy generando el archivo, creo que intentaré mantener el archivo en la memoria hasta que se acceda y luego liberaré la memoria después de descargarlo para que nunca tenga que escribir en el disco.
BVernon
5
$scope.downloadSearchAsCSV = function(httpOptions) {
  var httpOptions = _.extend({
    method: 'POST',
    url:    '',
    data:   null
  }, httpOptions);
  $http(httpOptions).then(function(response) {
    if( response.status >= 400 ) {
      alert(response.status + " - Server Error \nUnable to download CSV from POST\n" + JSON.stringify(httpOptions.data));
    } else {
      $scope.downloadResponseAsCSVFile(response)
    }
  })
};
/**
 * @source: https://github.com/asafdav/ng-csv/blob/master/src/ng-csv/directives/ng-csv.js
 * @param response
 */
$scope.downloadResponseAsCSVFile = function(response) {
  var charset = "utf-8";
  var filename = "search_results.csv";
  var blob = new Blob([response.data], {
    type: "text/csv;charset="+ charset + ";"
  });

  if (window.navigator.msSaveOrOpenBlob) {
    navigator.msSaveBlob(blob, filename); // @untested
  } else {
    var downloadContainer = angular.element('<div data-tap-disabled="true"><a></a></div>');
    var downloadLink      = angular.element(downloadContainer.children()[0]);
    downloadLink.attr('href', window.URL.createObjectURL(blob));
    downloadLink.attr('download', "search_results.csv");
    downloadLink.attr('target', '_blank');

    $document.find('body').append(downloadContainer);

    $timeout(function() {
      downloadLink[0].click();
      downloadLink.remove();
    }, null);
  }

  //// Gets blocked by Chrome popup-blocker
  //var csv_window = window.open("","","");
  //csv_window.document.write('<meta name="content-type" content="text/csv">');
  //csv_window.document.write('<meta name="content-disposition" content="attachment;  filename=data.csv">  ');
  //csv_window.document.write(response.data);
};
James McGuigan
fuente
3

No es una respuesta completa a la publicación original, sino una solución rápida y sucia para publicar un objeto json en el servidor y generar dinámicamente una descarga.

Lado del cliente jQuery:

var download = function(resource, payload) {
     $("#downloadFormPoster").remove();
     $("<div id='downloadFormPoster' style='display: none;'><iframe name='downloadFormPosterIframe'></iframe></div>").appendTo('body');
     $("<form action='" + resource + "' target='downloadFormPosterIframe' method='post'>" +
      "<input type='hidden' name='jsonstring' value='" + JSON.stringify(payload) + "'/>" +
      "</form>")
      .appendTo("#downloadFormPoster")
      .submit();
}

... y luego decodificando la cadena json en el lado del servidor y configurando los encabezados para descargar (ejemplo de PHP):

$request = json_decode($_POST['jsonstring']), true);
header('Content-Type: application/csv');
header('Content-Disposition: attachment; filename=export.csv');
header('Pragma: no-cache');
ralftar
fuente
Me gusta esta solución En la pregunta del OP, parece que el solicitante sabe si esperar una descarga de archivos o datos JSON, por lo que podría decidir en el extremo del cliente y publicar en una URL diferente, en lugar de decidir el extremo del servidor.
Sam
2

Creo que el mejor enfoque es usar una combinación. Su segundo enfoque parece ser una solución elegante para los navegadores.

Entonces, dependiendo de cómo se realice la llamada. (ya sea un navegador o una llamada de servicio web) puede usar una combinación de ambos, enviando una URL al navegador y enviando datos sin procesar a cualquier otro cliente de servicio web.

naikus
fuente
Mi segundo enfoque ahora me parece más atractivo. Gracias por confirmar que es una solución que vale la pena. ¿Sugiere que pase un valor adicional en el objeto json que indica que esta solicitud se realiza desde un navegador como una llamada ajax en lugar de una llamada de servicio web? Hay varias maneras de lograr esto en las que puedo pensar, pero ¿qué técnica usarías?
Tauren
¿no puede ser determinado por el encabezado http del User-Agent? O cualquier otro encabezado http?
naikus
1

He estado despierto durante dos días tratando de descubrir cómo descargar un archivo usando jquery con llamada ajax. Todo el apoyo que obtuve no pudo ayudar a mi situación hasta que lo intenté.

Lado del cliente

function exportStaffCSV(t) {
   
    var postData = { checkOne: t };
    $.ajax({
        type: "POST",
        url: "/Admin/Staff/exportStaffAsCSV",
        data: postData,
        success: function (data) {
            SuccessMessage("file download will start in few second..");
            var url = '/Admin/Staff/DownloadCSV?data=' + data;
            window.location = url;
        },
       
        traditional: true,
        error: function (xhr, status, p3, p4) {
            var err = "Error " + " " + status + " " + p3 + " " + p4;
            if (xhr.responseText && xhr.responseText[0] == "{")
                err = JSON.parse(xhr.responseText).Message;
            ErrorMessage(err);
        }
    });

}

Lado del servidor

 [HttpPost]
    public string exportStaffAsCSV(IEnumerable<string> checkOne)
    {
        StringWriter sw = new StringWriter();
        try
        {
            var data = _db.staffInfoes.Where(t => checkOne.Contains(t.staffID)).ToList();
            sw.WriteLine("\"First Name\",\"Last Name\",\"Other Name\",\"Phone Number\",\"Email Address\",\"Contact Address\",\"Date of Joining\"");
            foreach (var item in data)
            {
                sw.WriteLine(string.Format("\"{0}\",\"{1}\",\"{2}\",\"{3}\",\"{4}\",\"{5}\",\"{6}\"",
                    item.firstName,
                    item.lastName,
                    item.otherName,
                    item.phone,
                    item.email,
                    item.contact_Address,
                    item.doj
                    ));
            }
        }
        catch (Exception e)
        {

        }
        return sw.ToString();

    }

    //On ajax success request, it will be redirected to this method as a Get verb request with the returned date(string)
    public FileContentResult DownloadCSV(string data)
    {
        return File(new System.Text.UTF8Encoding().GetBytes(data), System.Net.Mime.MediaTypeNames.Application.Octet, filename);
        //this method will now return the file for download or open.
    }

Buena suerte.

Otis-iDev
fuente
2
Esto funcionará para archivos pequeños. Pero si tiene un archivo grande, se encontrará con el problema de que la URL es demasiado larga. Pruébelo con 1,000 registros, se romperá y solo exportará parte de los datos.
Michael Merrell
1

Lo encontré en algún lugar hace mucho tiempo y funciona perfectamente.

let payload = {
  key: "val",
  key2: "val2"
};

let url = "path/to/api.php";
let form = $('<form>', {'method': 'POST', 'action': url}).hide();
$.each(payload, (k, v) => form.append($('<input>', {'type': 'hidden', 'name': k, 'value': v})) );
$('body').append(form);
form.submit();
form.remove();
Den Nikitin
fuente
0

Otro enfoque en lugar de guardar el archivo en el servidor y recuperarlo, es usar .NET 4.0+ ObjectCache con una breve caducidad hasta la segunda Acción (momento en el que se puede volcar definitivamente). La razón por la que quiero usar JQuery Ajax para hacer la llamada es porque es asíncrona. Construir mi archivo PDF dinámico lleva bastante tiempo, y visualizo un cuadro de diálogo de spinner ocupado durante ese tiempo (también permite que se realice otro trabajo). El enfoque de usar los datos devueltos en el "éxito:" para crear un Blob no funciona de manera confiable. Depende del contenido del archivo PDF. Se daña fácilmente con los datos en la respuesta, si no es completamente textual, que es todo lo que Ajax puede manejar.

Wray Smallwood
fuente
0

Solución

El adjunto de disposición de contenido parece funcionar para mí:

self.set_header("Content-Type", "application/json")
self.set_header("Content-Disposition", 'attachment; filename=learned_data.json')

Solución alterna

application / octet-stream

Me sucedió algo similar con un JSON; para mí, en el lado del servidor, estaba configurando el encabezado en self.set_header ("Content-Type", " application / json "), sin embargo, cuando lo cambié a:

self.set_header("Content-Type", "application/octet-stream")

Lo descargó automáticamente.

También sepa que para que el archivo aún conserve el sufijo .json, lo necesitará en el encabezado del nombre de archivo:

self.set_header("Content-Disposition", 'filename=learned_data.json')
Mr-Programs
fuente
-1

Con HTML5, puede crear un ancla y hacer clic en él. No es necesario agregarlo al documento de niño.

const a = document.createElement('a');
a.download = '';
a.href = urlForPdfFile;
a.click();

Todo listo.

Si desea tener un nombre especial para la descarga, simplemente páselo en el downloadatributo:

const a = document.createElement('a');
a.download = 'my-special-name.pdf';
a.href = urlForPdfFile;
a.click();
reescrito
fuente
44
la pregunta es sobre el método POST
dovid